Compare commits
21 Commits
f19f653715
..
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 |
@@ -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) {
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -234,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);
|
||||||
@@ -253,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);
|
||||||
@@ -529,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()));
|
||||||
}
|
}
|
||||||
@@ -558,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())
|
||||||
@@ -567,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,11 +156,12 @@ public class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7) 예산
|
// 7) 예산 — 복구 시 현재 월 예산으로 등록(백업엔 월 정보 없음)
|
||||||
if (req.getBudgets() != null) {
|
if (req.getBudgets() != null) {
|
||||||
|
java.time.LocalDate today = java.time.LocalDate.now();
|
||||||
for (BudgetRequest b : req.getBudgets()) {
|
for (BudgetRequest b : req.getBudgets()) {
|
||||||
if (b == null || b.getCategory() == null) continue;
|
if (b == null || b.getCategory() == null) continue;
|
||||||
budgetService.create(b, memberId);
|
budgetService.create(b, memberId, today.getYear(), today.getMonthValue());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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; // 실패 시 직전 캐시라도
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -79,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,
|
||||||
@@ -96,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(
|
||||||
@@ -104,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();
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ public class Member implements Serializable {
|
|||||||
private String provider; // LOCAL / NAVER / GOOGLE
|
private String provider; // LOCAL / NAVER / GOOGLE
|
||||||
private String providerId; // 소셜 제공자 측 고유 ID
|
private String providerId; // 소셜 제공자 측 고유 ID
|
||||||
private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지)
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -40,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);
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ 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 검증용). 미설정 시 구글 로그인 비활성. */
|
/** 구글 OAuth 클라이언트 ID (ID 토큰 aud 검증용). 미설정 시 구글 로그인 비활성. */
|
||||||
@org.springframework.beans.factory.annotation.Value("${app.google-client-id:}")
|
@org.springframework.beans.factory.annotation.Value("${app.google-client-id:}")
|
||||||
@@ -186,6 +188,7 @@ public class AuthService {
|
|||||||
boolean emailVerified = "true".equals(String.valueOf(info.get("email_verified")));
|
boolean emailVerified = "true".equals(String.valueOf(info.get("email_verified")));
|
||||||
String name = info.get("name") != null ? String.valueOf(info.get("name"))
|
String name = info.get("name") != null ? String.valueOf(info.get("name"))
|
||||||
: (email != null ? email.split("@")[0] : "사용자");
|
: (email != null ? email.split("@")[0] : "사용자");
|
||||||
|
String picture = info.get("picture") != null ? String.valueOf(info.get("picture")) : null;
|
||||||
|
|
||||||
// 1) 구글 sub 로 이미 연결/가입된 계정 조회
|
// 1) 구글 sub 로 이미 연결/가입된 계정 조회
|
||||||
Member member = memberMapper.findByGoogleId(sub);
|
Member member = memberMapper.findByGoogleId(sub);
|
||||||
@@ -216,6 +219,7 @@ public class AuthService {
|
|||||||
.provider("GOOGLE")
|
.provider("GOOGLE")
|
||||||
.providerId(sub)
|
.providerId(sub)
|
||||||
.googleId(sub)
|
.googleId(sub)
|
||||||
|
.googlePicture(picture)
|
||||||
.role("USER")
|
.role("USER")
|
||||||
.status("ACTIVE")
|
.status("ACTIVE")
|
||||||
.build();
|
.build();
|
||||||
@@ -225,6 +229,11 @@ public class AuthService {
|
|||||||
if (!"ACTIVE".equals(member.getStatus())) {
|
if (!"ACTIVE".equals(member.getStatus())) {
|
||||||
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
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);
|
return issueSession(member, rememberMe);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,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() {
|
||||||
@@ -312,6 +366,15 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 현재 회원 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화용 */
|
||||||
|
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 백업)의 표시 이름도 동기화한다.
|
||||||
@@ -335,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()) {
|
||||||
@@ -361,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,
|
||||||
@@ -117,15 +138,23 @@ public class BoardController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 목록 태그 필터 — 게시판 지정 시 그 게시판에 매핑된 그룹의 태그만 */
|
||||||
@GetMapping("/tags")
|
@GetMapping("/tags")
|
||||||
public List<String> tags() {
|
public List<String> tags(@RequestParam(required = false) String category) {
|
||||||
|
if (category == null || category.isBlank()) {
|
||||||
return boardService.allTags();
|
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")
|
||||||
@@ -143,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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +22,8 @@ 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;
|
||||||
|
|||||||
@@ -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,6 +19,8 @@ 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;
|
||||||
@@ -28,6 +30,10 @@ public class PostDetail {
|
|||||||
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()
|
||||||
@@ -36,6 +42,8 @@ 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())
|
||||||
|
|||||||
@@ -13,9 +13,16 @@ 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 Boolean notice;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -5,14 +5,20 @@ import com.sb.web.board.domain.Comment;
|
|||||||
import com.sb.web.board.domain.Post;
|
import com.sb.web.board.domain.Post;
|
||||||
import com.sb.web.board.dto.CommentRequest;
|
import com.sb.web.board.dto.CommentRequest;
|
||||||
import com.sb.web.board.dto.CommentResponse;
|
import com.sb.web.board.dto.CommentResponse;
|
||||||
|
import com.sb.web.board.dto.CommentVoteSummary;
|
||||||
import com.sb.web.board.dto.PageResponse;
|
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.VoteResponse;
|
||||||
import com.sb.web.board.mapper.CommentMapper;
|
import com.sb.web.board.mapper.CommentMapper;
|
||||||
import com.sb.web.board.mapper.PostMapper;
|
import com.sb.web.board.mapper.PostMapper;
|
||||||
|
import com.sb.web.board.mapper.ReportMapper;
|
||||||
import com.sb.web.board.mapper.TagMapper;
|
import com.sb.web.board.mapper.TagMapper;
|
||||||
|
import com.sb.web.board.mapper.VoteMapper;
|
||||||
import com.sb.web.common.exception.ApiException;
|
import com.sb.web.common.exception.ApiException;
|
||||||
|
import com.sb.web.point.PointService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -20,7 +26,9 @@ 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.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,10 +41,24 @@ public class BoardService {
|
|||||||
private final PostMapper postMapper;
|
private final PostMapper postMapper;
|
||||||
private final TagMapper tagMapper;
|
private final TagMapper tagMapper;
|
||||||
private final CommentMapper commentMapper;
|
private final CommentMapper commentMapper;
|
||||||
|
private final VoteMapper voteMapper;
|
||||||
|
private final ReportMapper reportMapper;
|
||||||
|
private final PointService pointService;
|
||||||
private final RedisTemplate<String, Object> redisTemplate;
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
/** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */
|
/** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */
|
||||||
private static final Duration VIEW_DEDUP_TTL = Duration.ofHours(24);
|
private static final Duration VIEW_DEDUP_TTL = Duration.ofHours(24);
|
||||||
|
private static final String UP = "UP";
|
||||||
|
private static final String DOWN = "DOWN";
|
||||||
|
private static final String T_POST = "POST";
|
||||||
|
private static final String T_COMMENT = "COMMENT";
|
||||||
|
/** 신고 누적 블라인드 임계 */
|
||||||
|
private static final int REPORT_BLIND_THRESHOLD = 5;
|
||||||
|
|
||||||
|
// 인기 글/댓글: 등록 1일 이내 + 추천 50건 이상이면 상단에 최대 3건 노출
|
||||||
|
private static final int HOT_WITHIN_HOURS = 24;
|
||||||
|
private static final int HOT_MIN_UP = 50;
|
||||||
|
private static final int HOT_MAX = 3;
|
||||||
|
|
||||||
/* ===================== 게시글 ===================== */
|
/* ===================== 게시글 ===================== */
|
||||||
|
|
||||||
@@ -50,9 +72,39 @@ public class BoardService {
|
|||||||
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
|
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
|
||||||
List<PostSummary> content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st);
|
List<PostSummary> content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st);
|
||||||
long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st);
|
long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st);
|
||||||
|
|
||||||
|
// 1페이지·검색 없을 때만 인기 글을 최상단에 노출 (중복 제거)
|
||||||
|
if (p == 1 && blankToNull(tag) == null && blankToNull(keyword) == null) {
|
||||||
|
List<PostSummary> hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX);
|
||||||
|
if (!hot.isEmpty()) {
|
||||||
|
java.util.Set<Long> hotIds = new java.util.HashSet<>();
|
||||||
|
hot.forEach(h -> { h.setHot(true); hotIds.add(h.getId()); });
|
||||||
|
List<PostSummary> merged = new java.util.ArrayList<>(hot);
|
||||||
|
for (PostSummary ps : content) {
|
||||||
|
if (!hotIds.contains(ps.getId())) merged.add(ps);
|
||||||
|
}
|
||||||
|
content = merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
return PageResponse.of(content, p, s, total);
|
return PageResponse.of(content, p, s, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 내 글 모아보기 */
|
||||||
|
public PageResponse<PostSummary> myPosts(Long memberId, int page, int size) {
|
||||||
|
int p = Math.max(page, 1);
|
||||||
|
int s = Math.min(Math.max(size, 1), 100);
|
||||||
|
List<PostSummary> content = postMapper.findMyPage(memberId, (p - 1) * s, s);
|
||||||
|
return PageResponse.of(content, p, s, postMapper.countMyPosts(memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 내 댓글 모아보기 */
|
||||||
|
public PageResponse<com.sb.web.board.dto.MyCommentResponse> myComments(Long memberId, int page, int size) {
|
||||||
|
int p = Math.max(page, 1);
|
||||||
|
int s = Math.min(Math.max(size, 1), 100);
|
||||||
|
var content = commentMapper.findMyPage(memberId, (p - 1) * s, s);
|
||||||
|
return PageResponse.of(content, p, s, commentMapper.countMyComments(memberId));
|
||||||
|
}
|
||||||
|
|
||||||
private String normalizeSearchType(String t) {
|
private String normalizeSearchType(String t) {
|
||||||
return ("content".equals(t) || "comment".equals(t)) ? t : "title";
|
return ("content".equals(t) || "comment".equals(t)) ? t : "title";
|
||||||
}
|
}
|
||||||
@@ -80,7 +132,7 @@ public class BoardService {
|
|||||||
postMapper.increaseViewCount(id);
|
postMapper.increaseViewCount(id);
|
||||||
post.setViewCount(post.getViewCount() + 1);
|
post.setViewCount(post.getViewCount() + 1);
|
||||||
}
|
}
|
||||||
return assemble(post);
|
return assemble(post, viewer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 게시글 열람 제한/해제 (관리자 전용) */
|
/** 게시글 열람 제한/해제 (관리자 전용) */
|
||||||
@@ -89,6 +141,22 @@ public class BoardService {
|
|||||||
assertAdmin(user);
|
assertAdmin(user);
|
||||||
mustFindPost(id);
|
mustFindPost(id);
|
||||||
postMapper.updateBlocked(id, blocked, blocked ? reason : null);
|
postMapper.updateBlocked(id, blocked, blocked ? reason : null);
|
||||||
|
// 해제 시 신고 기록 초기화 → 다음 신고 1건에 곧바로 재블라인드되지 않게
|
||||||
|
if (!blocked) {
|
||||||
|
reportMapper.deleteByTarget(T_POST, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 댓글 블라인드 해제 (관리자 전용) — 신고 기록 초기화 */
|
||||||
|
@Transactional
|
||||||
|
public void unblockComment(Long commentId, SessionUser user) {
|
||||||
|
assertAdmin(user);
|
||||||
|
Comment comment = commentMapper.findById(commentId);
|
||||||
|
if (comment == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
commentMapper.updateBlocked(commentId, false);
|
||||||
|
reportMapper.deleteByTarget(T_COMMENT, commentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 게시글 공지 설정/해제 (관리자 전용) — 목록 최상단 고정 */
|
/** 게시글 공지 설정/해제 (관리자 전용) — 목록 최상단 고정 */
|
||||||
@@ -122,7 +190,8 @@ public class BoardService {
|
|||||||
.build();
|
.build();
|
||||||
postMapper.insert(post);
|
postMapper.insert(post);
|
||||||
applyTags(post.getId(), req.getTagIds());
|
applyTags(post.getId(), req.getTagIds());
|
||||||
return assemble(mustFindPost(post.getId()));
|
pointService.awardBoardWrite(author.getId()); // 글 작성 보상(일 3회 한도)
|
||||||
|
return assemble(mustFindPost(post.getId()), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -144,13 +213,17 @@ public class BoardService {
|
|||||||
|
|
||||||
tagMapper.deletePostTagsByPostId(id);
|
tagMapper.deletePostTagsByPostId(id);
|
||||||
applyTags(id, req.getTagIds());
|
applyTags(id, req.getTagIds());
|
||||||
return assemble(mustFindPost(id));
|
return assemble(mustFindPost(id), user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long id, SessionUser user) {
|
public void delete(Long id, SessionUser user) {
|
||||||
Post post = mustFindPost(id);
|
Post post = mustFindPost(id);
|
||||||
assertCanModify(post.getAuthorId(), user);
|
assertCanModify(post.getAuthorId(), user);
|
||||||
|
// 투표 정리(댓글 투표는 댓글 삭제 전에 — 서브쿼리가 comment 참조)
|
||||||
|
voteMapper.deleteCommentVotesByPost(id);
|
||||||
|
voteMapper.deletePostVotesByPost(id);
|
||||||
|
reportMapper.deleteByTarget(T_POST, id);
|
||||||
tagMapper.deletePostTagsByPostId(id);
|
tagMapper.deletePostTagsByPostId(id);
|
||||||
commentMapper.deleteByPostId(id);
|
commentMapper.deleteByPostId(id);
|
||||||
postMapper.delete(id);
|
postMapper.delete(id);
|
||||||
@@ -171,6 +244,7 @@ public class BoardService {
|
|||||||
.content(req.getContent())
|
.content(req.getContent())
|
||||||
.build();
|
.build();
|
||||||
commentMapper.insert(comment);
|
commentMapper.insert(comment);
|
||||||
|
pointService.awardBoardWrite(author.getId()); // 댓글 작성 보상(일 3회 한도, 글과 합산)
|
||||||
return CommentResponse.from(commentMapper.findById(comment.getId()));
|
return CommentResponse.from(commentMapper.findById(comment.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,9 +255,97 @@ public class BoardService {
|
|||||||
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
assertCanModify(comment.getAuthorId(), user);
|
assertCanModify(comment.getAuthorId(), user);
|
||||||
|
voteMapper.deleteCommentVotesByComment(commentId);
|
||||||
|
reportMapper.deleteByTarget(T_COMMENT, commentId);
|
||||||
commentMapper.delete(commentId);
|
commentMapper.delete(commentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== 신고 ===================== */
|
||||||
|
|
||||||
|
/** 게시글 신고 — 회원당 1회, 누적 5건이면 블라인드(관리자만 열람) */
|
||||||
|
@Transactional
|
||||||
|
public void reportPost(Long postId, SessionUser user) {
|
||||||
|
Post post = mustFindPost(postId);
|
||||||
|
if (post.getAuthorId().equals(user.getId())) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 글은 신고할 수 없습니다.");
|
||||||
|
}
|
||||||
|
reportMapper.insertIgnore(T_POST, postId, user.getId(), null);
|
||||||
|
if (!Boolean.TRUE.equals(post.getBlocked())
|
||||||
|
&& reportMapper.countByTarget(T_POST, postId) >= REPORT_BLIND_THRESHOLD) {
|
||||||
|
postMapper.updateBlocked(postId, true, "신고 누적으로 블라인드되었습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 댓글 신고 — 회원당 1회, 누적 5건이면 블라인드(관리자만 열람) */
|
||||||
|
@Transactional
|
||||||
|
public void reportComment(Long commentId, SessionUser user) {
|
||||||
|
Comment comment = commentMapper.findById(commentId);
|
||||||
|
if (comment == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
if (comment.getAuthorId().equals(user.getId())) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 댓글은 신고할 수 없습니다.");
|
||||||
|
}
|
||||||
|
reportMapper.insertIgnore(T_COMMENT, commentId, user.getId(), null);
|
||||||
|
if (!Boolean.TRUE.equals(comment.getBlocked())
|
||||||
|
&& reportMapper.countByTarget(T_COMMENT, commentId) >= REPORT_BLIND_THRESHOLD) {
|
||||||
|
commentMapper.updateBlocked(commentId, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== 추천/비추천 ===================== */
|
||||||
|
|
||||||
|
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
|
||||||
|
@Transactional
|
||||||
|
public VoteResponse votePost(Long postId, String type, SessionUser user) {
|
||||||
|
Post post = mustFindPost(postId);
|
||||||
|
if (post.getAuthorId().equals(user.getId())) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 글에는 추천/비추천할 수 없습니다.");
|
||||||
|
}
|
||||||
|
Long memberId = user.getId();
|
||||||
|
String existing = voteMapper.findPostVote(postId, memberId);
|
||||||
|
boolean cancel = type.equals(existing);
|
||||||
|
if (cancel) {
|
||||||
|
voteMapper.deletePostVote(postId, memberId);
|
||||||
|
} else {
|
||||||
|
voteMapper.upsertPostVote(postId, memberId, type);
|
||||||
|
}
|
||||||
|
return new VoteResponse(
|
||||||
|
voteMapper.countPostVotes(postId, UP),
|
||||||
|
voteMapper.countPostVotes(postId, DOWN),
|
||||||
|
cancel ? null : type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 댓글 추천/비추천 토글 */
|
||||||
|
@Transactional
|
||||||
|
public VoteResponse voteComment(Long commentId, String type, SessionUser user) {
|
||||||
|
Comment comment = commentMapper.findById(commentId);
|
||||||
|
if (comment == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
if (comment.getAuthorId().equals(user.getId())) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 댓글에는 추천/비추천할 수 없습니다.");
|
||||||
|
}
|
||||||
|
Long memberId = user.getId();
|
||||||
|
String existing = voteMapper.findCommentVote(commentId, memberId);
|
||||||
|
boolean cancel = type.equals(existing);
|
||||||
|
if (cancel) {
|
||||||
|
voteMapper.deleteCommentVote(commentId, memberId);
|
||||||
|
} else {
|
||||||
|
voteMapper.upsertCommentVote(commentId, memberId, type);
|
||||||
|
}
|
||||||
|
return new VoteResponse(
|
||||||
|
voteMapper.countCommentVotes(commentId, UP),
|
||||||
|
voteMapper.countCommentVotes(commentId, DOWN),
|
||||||
|
cancel ? null : type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 추천(👍)한 사람 목록 (본문 하단 표기/모달용) */
|
||||||
|
public List<Recommender> recommenders(Long postId) {
|
||||||
|
mustFindPost(postId);
|
||||||
|
return voteMapper.findPostRecommenders(postId);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== 태그 ===================== */
|
/* ===================== 태그 ===================== */
|
||||||
|
|
||||||
public List<String> allTags() {
|
public List<String> allTags() {
|
||||||
@@ -192,11 +354,62 @@ public class BoardService {
|
|||||||
|
|
||||||
/* ===================== 내부 ===================== */
|
/* ===================== 내부 ===================== */
|
||||||
|
|
||||||
private PostDetail assemble(Post post) {
|
private PostDetail assemble(Post post, SessionUser viewer) {
|
||||||
List<String> tags = tagMapper.findNamesByPostId(post.getId());
|
Long postId = post.getId();
|
||||||
List<CommentResponse> comments = commentMapper.findByPostId(post.getId())
|
Long viewerId = viewer == null ? null : viewer.getId();
|
||||||
.stream().map(CommentResponse::from).toList();
|
List<String> tags = tagMapper.findNamesByPostId(postId);
|
||||||
return PostDetail.of(post, tags, comments);
|
|
||||||
|
// 댓글별 추천/비추천 집계를 한 번에 조회(N+1 방지)
|
||||||
|
Map<Long, CommentVoteSummary> voteByComment = new HashMap<>();
|
||||||
|
for (CommentVoteSummary s : voteMapper.findCommentVoteSummary(postId, viewerId)) {
|
||||||
|
voteByComment.put(s.getCommentId(), s);
|
||||||
|
}
|
||||||
|
List<CommentResponse> comments = new java.util.ArrayList<>(commentMapper.findByPostId(postId).stream().map(c -> {
|
||||||
|
CommentResponse cr = CommentResponse.from(c);
|
||||||
|
CommentVoteSummary s = voteByComment.get(c.getId());
|
||||||
|
if (s != null) {
|
||||||
|
cr.setUpCount(s.getUpCount());
|
||||||
|
cr.setDownCount(s.getDownCount());
|
||||||
|
cr.setMyVote(s.getMyVote());
|
||||||
|
}
|
||||||
|
return cr;
|
||||||
|
}).toList());
|
||||||
|
// 신고 누적 블라인드 댓글: 관리자가 아니면 본문 숨김
|
||||||
|
boolean viewerAdmin = isAdmin(viewer);
|
||||||
|
if (!viewerAdmin) {
|
||||||
|
comments.forEach(cr -> {
|
||||||
|
if (Boolean.TRUE.equals(cr.getBlocked())) cr.setContent(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
comments = orderWithHot(comments);
|
||||||
|
|
||||||
|
PostDetail detail = PostDetail.of(post, tags, comments);
|
||||||
|
detail.setUpCount(voteMapper.countPostVotes(postId, UP));
|
||||||
|
detail.setDownCount(voteMapper.countPostVotes(postId, DOWN));
|
||||||
|
detail.setMyVote(viewerId == null ? null : voteMapper.findPostVote(postId, viewerId));
|
||||||
|
detail.setRecommenders(voteMapper.findPostRecommenders(postId));
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 인기 댓글(추천 50+ & 1일 이내) 최대 3건을 추천 많은 순으로 상단에 올리고 hot 표시 */
|
||||||
|
private List<CommentResponse> orderWithHot(List<CommentResponse> comments) {
|
||||||
|
java.time.LocalDateTime cutoff = java.time.LocalDateTime.now().minusHours(HOT_WITHIN_HOURS);
|
||||||
|
List<CommentResponse> hot = comments.stream()
|
||||||
|
.filter(c -> c.getUpCount() != null && c.getUpCount() >= HOT_MIN_UP
|
||||||
|
&& c.getCreatedAt() != null && c.getCreatedAt().isAfter(cutoff))
|
||||||
|
.sorted((a, b) -> Integer.compare(b.getUpCount(), a.getUpCount()))
|
||||||
|
.limit(HOT_MAX)
|
||||||
|
.toList();
|
||||||
|
if (hot.isEmpty()) {
|
||||||
|
return comments;
|
||||||
|
}
|
||||||
|
java.util.Set<Long> hotIds = new java.util.HashSet<>();
|
||||||
|
hot.forEach(c -> { c.setHot(true); hotIds.add(c.getId()); });
|
||||||
|
List<CommentResponse> ordered = new java.util.ArrayList<>(hot);
|
||||||
|
for (CommentResponse c : comments) {
|
||||||
|
if (!hotIds.contains(c.getId())) ordered.add(c);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 선택된 태그 ID 중 실제 DB에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */
|
/** 선택된 태그 ID 중 실제 DB에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import com.sb.web.board.domain.Tag;
|
|||||||
import com.sb.web.board.domain.TagCategory;
|
import com.sb.web.board.domain.TagCategory;
|
||||||
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.BoardSettingResponse;
|
|
||||||
import com.sb.web.board.dto.TagRequest;
|
import com.sb.web.board.dto.TagRequest;
|
||||||
import com.sb.web.board.dto.TagResponse;
|
import com.sb.web.board.dto.TagResponse;
|
||||||
import com.sb.web.board.mapper.BoardSettingMapper;
|
import com.sb.web.board.mapper.TagCategoryBoardMapper;
|
||||||
import com.sb.web.board.mapper.TagCategoryMapper;
|
import com.sb.web.board.mapper.TagCategoryMapper;
|
||||||
import com.sb.web.board.mapper.TagMapper;
|
import com.sb.web.board.mapper.TagMapper;
|
||||||
import com.sb.web.common.exception.ApiException;
|
import com.sb.web.common.exception.ApiException;
|
||||||
@@ -18,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 태그 카테고리 + 태그 관리 서비스 (관리자 기능 + 그룹 조회).
|
* 태그 카테고리 + 태그 관리 서비스 (관리자 기능 + 그룹 조회).
|
||||||
@@ -28,7 +28,10 @@ public class TagService {
|
|||||||
|
|
||||||
private final TagCategoryMapper categoryMapper;
|
private final TagCategoryMapper categoryMapper;
|
||||||
private final TagMapper tagMapper;
|
private final TagMapper tagMapper;
|
||||||
private final BoardSettingMapper boardSettingMapper;
|
private final TagCategoryBoardMapper categoryBoardMapper;
|
||||||
|
|
||||||
|
/** 게시판 매핑에 허용되는 값 */
|
||||||
|
private static final Set<String> VALID_BOARDS = Set.of("community", "saving", "tips");
|
||||||
|
|
||||||
/** 카테고리별로 묶은 전체 태그 (관리 화면용) */
|
/** 카테고리별로 묶은 전체 태그 (관리 화면용) */
|
||||||
public List<TagCategoryResponse> listGrouped() {
|
public List<TagCategoryResponse> listGrouped() {
|
||||||
@@ -39,37 +42,43 @@ public class TagService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 글 작성 시 선택 가능한 태그 그룹 — 게시판 설정 카테고리로 제한 (없으면 전체) */
|
/**
|
||||||
public List<TagCategoryResponse> listWritableGroups() {
|
* 글 작성 시 선택 가능한 태그 그룹 — 해당 게시판에 명시적으로 매핑된 그룹만(엄격).
|
||||||
Long catId = boardSettingMapper.findTagCategoryId();
|
* 게시판을 하나도 지정하지 않은 그룹은 어디에도 노출되지 않는다.
|
||||||
if (catId == null) {
|
*/
|
||||||
|
public List<TagCategoryResponse> listWritableGroups(String boardCategory) {
|
||||||
|
// 게시판 미지정(수정 화면 등) → 전체 그룹 (기존 글 태그 유실 방지)
|
||||||
|
if (boardCategory == null || boardCategory.isBlank()) {
|
||||||
return listGrouped();
|
return listGrouped();
|
||||||
}
|
}
|
||||||
TagCategory c = categoryMapper.findById(catId);
|
List<TagCategoryResponse> result = new ArrayList<>();
|
||||||
return c == null ? listGrouped() : List.of(toGroup(c));
|
for (TagCategory c : categoryMapper.findAll()) {
|
||||||
|
TagCategoryResponse g = toGroup(c);
|
||||||
|
List<String> boards = g.getBoards();
|
||||||
|
if (boards != null && boards.contains(boardCategory)) {
|
||||||
|
result.add(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private TagCategoryResponse toGroup(TagCategory c) {
|
private TagCategoryResponse toGroup(TagCategory c) {
|
||||||
List<TagResponse> tags = tagMapper.findByCategoryId(c.getId())
|
List<TagResponse> tags = tagMapper.findByCategoryId(c.getId())
|
||||||
.stream().map(TagResponse::from).toList();
|
.stream().map(TagResponse::from).toList();
|
||||||
return TagCategoryResponse.of(c, tags);
|
List<String> boards = categoryBoardMapper.findBoards(c.getId());
|
||||||
|
return TagCategoryResponse.of(c, tags, boards);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== 게시판 설정 ===================== */
|
/** 카테고리의 게시판 매핑을 교체 저장 (유효 게시판 값만, 중복 제거) */
|
||||||
|
private void saveBoards(Long categoryId, List<String> boards) {
|
||||||
public BoardSettingResponse getBoardSetting() {
|
categoryBoardMapper.deleteByCategory(categoryId);
|
||||||
return BoardSettingResponse.builder()
|
if (boards == null) {
|
||||||
.tagCategoryId(boardSettingMapper.findTagCategoryId())
|
return;
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
boards.stream()
|
||||||
@Transactional
|
.filter(b -> b != null && VALID_BOARDS.contains(b))
|
||||||
public BoardSettingResponse setBoardSetting(Long tagCategoryId) {
|
.distinct()
|
||||||
if (tagCategoryId != null && categoryMapper.findById(tagCategoryId) == null) {
|
.forEach(b -> categoryBoardMapper.insert(categoryId, b));
|
||||||
throw new ApiException(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
boardSettingMapper.updateTagCategoryId(tagCategoryId);
|
|
||||||
return BoardSettingResponse.builder().tagCategoryId(tagCategoryId).build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== 카테고리 ===================== */
|
/* ===================== 카테고리 ===================== */
|
||||||
@@ -85,7 +94,8 @@ public class TagService {
|
|||||||
.sortOrder(req.getSortOrder() != null ? req.getSortOrder() : 0)
|
.sortOrder(req.getSortOrder() != null ? req.getSortOrder() : 0)
|
||||||
.build();
|
.build();
|
||||||
categoryMapper.insert(c);
|
categoryMapper.insert(c);
|
||||||
return TagCategoryResponse.of(c, List.of());
|
saveBoards(c.getId(), req.getBoards());
|
||||||
|
return TagCategoryResponse.of(c, List.of(), categoryBoardMapper.findBoards(c.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -100,11 +110,12 @@ public class TagService {
|
|||||||
c.setSortOrder(req.getSortOrder());
|
c.setSortOrder(req.getSortOrder());
|
||||||
}
|
}
|
||||||
categoryMapper.update(c);
|
categoryMapper.update(c);
|
||||||
|
saveBoards(id, req.getBoards());
|
||||||
List<TagResponse> tags = tagMapper.findByCategoryId(id).stream().map(TagResponse::from).toList();
|
List<TagResponse> tags = tagMapper.findByCategoryId(id).stream().map(TagResponse::from).toList();
|
||||||
return TagCategoryResponse.of(c, tags);
|
return TagCategoryResponse.of(c, tags, categoryBoardMapper.findBoards(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 카테고리 삭제 — 소속 태그와 그 연결(post_tag)까지 함께 삭제 */
|
/** 카테고리 삭제 — 소속 태그와 그 연결(post_tag)·게시판 매핑까지 함께 삭제 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteCategory(Long id) {
|
public void deleteCategory(Long id) {
|
||||||
mustFindCategory(id);
|
mustFindCategory(id);
|
||||||
@@ -112,6 +123,7 @@ public class TagService {
|
|||||||
tagMapper.deletePostTagsByTagId(t.getId());
|
tagMapper.deletePostTagsByTagId(t.getId());
|
||||||
tagMapper.delete(t.getId());
|
tagMapper.delete(t.getId());
|
||||||
}
|
}
|
||||||
|
categoryBoardMapper.deleteByCategory(id);
|
||||||
categoryMapper.delete(id);
|
categoryMapper.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.sb.web.common.config;
|
|||||||
|
|
||||||
import com.sb.web.auth.web.AdminInterceptor;
|
import com.sb.web.auth.web.AdminInterceptor;
|
||||||
import com.sb.web.auth.web.AuthInterceptor;
|
import com.sb.web.auth.web.AuthInterceptor;
|
||||||
|
import com.sb.web.auth.web.PremiumInterceptor;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
@@ -18,16 +19,33 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
|
|
||||||
private final AuthInterceptor authInterceptor;
|
private final AuthInterceptor authInterceptor;
|
||||||
private final AdminInterceptor adminInterceptor;
|
private final AdminInterceptor adminInterceptor;
|
||||||
|
private final PremiumInterceptor premiumInterceptor;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
|
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
|
||||||
registry.addInterceptor(authInterceptor)
|
registry.addInterceptor(authInterceptor)
|
||||||
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/auth/password",
|
.addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history",
|
||||||
"/api/auth/verify-password", "/api/auth/profile",
|
"/api/auth/logout", "/api/auth/password",
|
||||||
"/api/board/**", "/api/admin/**", "/api/account/**");
|
"/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image",
|
||||||
|
"/api/board/**", "/api/admin/**", "/api/account/**", "/api/billing/**", "/api/fx/**")
|
||||||
|
// Google Play RTDN(서버 알림)은 비인증 — Google 이 호출
|
||||||
|
.excludePathPatterns("/api/billing/google/rtdn");
|
||||||
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
|
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
|
||||||
registry.addInterceptor(adminInterceptor)
|
registry.addInterceptor(adminInterceptor)
|
||||||
.addPathPatterns("/api/admin/**");
|
.addPathPatterns("/api/admin/**");
|
||||||
|
// 인가: 유료(PREMIUM) 전용 경로 (authInterceptor 다음에 실행되어 plan 검사)
|
||||||
|
registry.addInterceptor(premiumInterceptor)
|
||||||
|
.addPathPatterns(
|
||||||
|
"/api/account/stats",
|
||||||
|
"/api/account/category-stats",
|
||||||
|
"/api/account/networth/trend",
|
||||||
|
"/api/account/budgets", "/api/account/budgets/**",
|
||||||
|
"/api/account/recurrings", "/api/account/recurrings/**",
|
||||||
|
"/api/account/ocr", "/api/account/ocr/**",
|
||||||
|
"/api/account/invest", "/api/account/invest/**",
|
||||||
|
"/api/account/tags", "/api/account/tags/**",
|
||||||
|
"/api/account/entries/notification",
|
||||||
|
"/api/account/restore");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.sb.web.point;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포인트 적립/차감 내역 항목.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PointHistoryResponse {
|
||||||
|
|
||||||
|
private Integer amount; // 증감 포인트(+/-)
|
||||||
|
private String reason; // BOARD_WRITE 등
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.sb.web.point;
|
||||||
|
|
||||||
|
import com.sb.web.point.mapper.PointMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활동 포인트 적립/조회.
|
||||||
|
* 게시판 글/댓글 작성 시 10점, 하루 3회까지만 지급(글+댓글 합산).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PointService {
|
||||||
|
|
||||||
|
private final PointMapper pointMapper;
|
||||||
|
|
||||||
|
public static final int BOARD_WRITE_POINT = 10;
|
||||||
|
public static final int BOARD_WRITE_DAILY_LIMIT = 3;
|
||||||
|
public static final String REASON_BOARD_WRITE = "BOARD_WRITE";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시판 글/댓글 작성 보상. 오늘 지급 횟수가 한도 미만일 때만 지급한다.
|
||||||
|
* @return 지급되면 지급 포인트, 한도 초과로 미지급이면 0
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int awardBoardWrite(Long memberId) {
|
||||||
|
int today = pointMapper.countTodayGrants(memberId, REASON_BOARD_WRITE);
|
||||||
|
if (today >= BOARD_WRITE_DAILY_LIMIT) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
pointMapper.insertHistory(memberId, BOARD_WRITE_POINT, REASON_BOARD_WRITE);
|
||||||
|
pointMapper.addPoints(memberId, BOARD_WRITE_POINT);
|
||||||
|
return BOARD_WRITE_POINT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getPoints(Long memberId) {
|
||||||
|
Long p = pointMapper.getPoints(memberId);
|
||||||
|
return p == null ? 0L : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 최근 포인트 적립/차감 내역 (최신순) */
|
||||||
|
public java.util.List<PointHistoryResponse> getHistory(Long memberId) {
|
||||||
|
return pointMapper.findHistory(memberId, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.sb.web.point.mapper;
|
||||||
|
|
||||||
|
import com.sb.web.point.PointHistoryResponse;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포인트(member.points) 및 적립 이력(point_history) 매퍼.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface PointMapper {
|
||||||
|
|
||||||
|
/** 오늘 특정 사유로 지급된 횟수 (일일 한도 계산용) */
|
||||||
|
int countTodayGrants(@Param("memberId") Long memberId, @Param("reason") String reason);
|
||||||
|
|
||||||
|
int insertHistory(@Param("memberId") Long memberId, @Param("amount") int amount, @Param("reason") String reason);
|
||||||
|
|
||||||
|
int addPoints(@Param("memberId") Long memberId, @Param("amount") int amount);
|
||||||
|
|
||||||
|
Long getPoints(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
/** 최근 적립/차감 내역 (최신순, 최대 limit건) */
|
||||||
|
List<PointHistoryResponse> findHistory(@Param("memberId") Long memberId, @Param("limit") int limit);
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ ALTER TABLE wallet ADD COLUMN IF NOT EXISTS opening_balance BIGINT NOT NULL DEFA
|
|||||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS opening_date DATE NULL;
|
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS opening_date DATE NULL;
|
||||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS current_value BIGINT NULL;
|
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS current_value BIGINT NULL;
|
||||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_rate DECIMAL(7,4) NULL COMMENT '연이자율(%) 예: 5.2500';
|
||||||
|
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_method VARCHAR(20) NULL COMMENT '상환방식: EQUAL_PAYMENT/EQUAL_PRINCIPAL/BULLET';
|
||||||
|
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_months INT NULL COMMENT '대출기간(개월)';
|
||||||
|
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_start DATE NULL COMMENT '대출시작일';
|
||||||
|
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_amount BIGINT NULL COMMENT '대출 실행 금액(원금, 처음 빌린 금액)';
|
||||||
-- 계좌번호 암호화 저장(AES-GCM)으로 평문보다 길어 VARCHAR(255) 필요 — 운영 적용 완료(CREATE TABLE 정의에 반영).
|
-- 계좌번호 암호화 저장(AES-GCM)으로 평문보다 길어 VARCHAR(255) 필요 — 운영 적용 완료(CREATE TABLE 정의에 반영).
|
||||||
-- 매 기동 MODIFY 는 라이브 테이블 락 위험이 있어 제거. 기존 환경 확장이 필요하면 1회 수동 적용:
|
-- 매 기동 MODIFY 는 라이브 테이블 락 위험이 있어 제거. 기존 환경 확장이 필요하면 1회 수동 적용:
|
||||||
-- ALTER TABLE wallet MODIFY account_number VARCHAR(255) NULL;
|
-- ALTER TABLE wallet MODIFY account_number VARCHAR(255) NULL;
|
||||||
@@ -59,6 +64,10 @@ ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS installment_months INT NULL;
|
|||||||
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS pending TINYINT(1) NOT NULL DEFAULT 0 COMMENT '확인 필요(미확정) 여부';
|
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS pending TINYINT(1) NOT NULL DEFAULT 0 COMMENT '확인 필요(미확정) 여부';
|
||||||
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS notif_key VARCHAR(160) NULL COMMENT '알림 자동인식 중복방지 키';
|
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS notif_key VARCHAR(160) NULL COMMENT '알림 자동인식 중복방지 키';
|
||||||
ALTER TABLE account_entry ADD INDEX IF NOT EXISTS idx_entry_notif (member_id, notif_key);
|
ALTER TABLE account_entry ADD INDEX IF NOT EXISTS idx_entry_notif (member_id, notif_key);
|
||||||
|
-- 외화 결제: amount 는 환산된 원화(표준값, 통계·예산은 이 값 사용). 아래는 외화 원본 보존용.
|
||||||
|
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS currency VARCHAR(3) NULL COMMENT '통화코드(USD/JPY 등). NULL/KRW=원화';
|
||||||
|
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS original_amount DECIMAL(18,2) NULL COMMENT '외화 원본 금액';
|
||||||
|
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS exchange_rate DECIMAL(18,6) NULL COMMENT '적용 환율(외화 1단위→원화)';
|
||||||
|
|
||||||
-- 가계부 태그 (사용자별 관리 — 게시판 태그와 분리)
|
-- 가계부 태그 (사용자별 관리 — 게시판 태그와 분리)
|
||||||
CREATE TABLE IF NOT EXISTS account_tag (
|
CREATE TABLE IF NOT EXISTS account_tag (
|
||||||
@@ -161,6 +170,15 @@ CREATE TABLE IF NOT EXISTS budget (
|
|||||||
UNIQUE KEY uk_budget_member_category (member_id, category)
|
UNIQUE KEY uk_budget_member_category (member_id, category)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 월별 예산: 예산을 연/월별로 분리(전월 복사로 다음 달에 동일 예산 저장 가능).
|
||||||
|
ALTER TABLE budget ADD COLUMN IF NOT EXISTS `year` INT NULL COMMENT '예산 연도';
|
||||||
|
ALTER TABLE budget ADD COLUMN IF NOT EXISTS `month` INT NULL COMMENT '예산 월(1~12)';
|
||||||
|
-- 기존 상시 예산을 현재 연/월로 1회 이관
|
||||||
|
UPDATE budget SET `year` = YEAR(NOW()), `month` = MONTH(NOW()) WHERE `year` IS NULL OR `month` IS NULL;
|
||||||
|
-- 분류 유니크를 (member,category) → (member,category,year,month) 로 교체
|
||||||
|
ALTER TABLE budget DROP INDEX IF EXISTS uk_budget_member_category;
|
||||||
|
ALTER TABLE budget ADD UNIQUE KEY IF NOT EXISTS uk_budget_member_cat_ym (member_id, category, `year`, `month`);
|
||||||
|
|
||||||
-- 가계부 항목 - 태그 (다대다, tag_id 는 account_tag.id 참조)
|
-- 가계부 항목 - 태그 (다대다, tag_id 는 account_tag.id 참조)
|
||||||
CREATE TABLE IF NOT EXISTS account_entry_tag (
|
CREATE TABLE IF NOT EXISTS account_entry_tag (
|
||||||
entry_id BIGINT NOT NULL,
|
entry_id BIGINT NOT NULL,
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ CREATE TABLE IF NOT EXISTS tag (
|
|||||||
-- 기존 tag 테이블에 category_id 가 없으면 추가 (멱등)
|
-- 기존 tag 테이블에 category_id 가 없으면 추가 (멱등)
|
||||||
ALTER TABLE tag ADD COLUMN IF NOT EXISTS category_id BIGINT NULL;
|
ALTER TABLE tag ADD COLUMN IF NOT EXISTS category_id BIGINT NULL;
|
||||||
|
|
||||||
|
-- 태그 카테고리(그룹) ↔ 게시판 매핑. 어느 게시판(community/saving/tips)에서 이 그룹을 쓸지 지정.
|
||||||
|
-- 매핑이 하나도 없는 카테고리는 모든 게시판에서 사용(하위호환).
|
||||||
|
CREATE TABLE IF NOT EXISTS tag_category_board (
|
||||||
|
tag_category_id BIGINT NOT NULL,
|
||||||
|
board_category VARCHAR(30) NOT NULL COMMENT 'community / saving / tips',
|
||||||
|
PRIMARY KEY (tag_category_id, board_category),
|
||||||
|
KEY idx_tcb_board (board_category)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 게시글-태그 (다대다)
|
-- 게시글-태그 (다대다)
|
||||||
CREATE TABLE IF NOT EXISTS post_tag (
|
CREATE TABLE IF NOT EXISTS post_tag (
|
||||||
post_id BIGINT NOT NULL,
|
post_id BIGINT NOT NULL,
|
||||||
@@ -62,16 +71,6 @@ CREATE TABLE IF NOT EXISTS post_tag (
|
|||||||
KEY idx_post_tag_tag (tag_id)
|
KEY idx_post_tag_tag (tag_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 게시판 설정 (단일 행) — 게시판에서 사용할 태그 카테고리 지정
|
|
||||||
CREATE TABLE IF NOT EXISTS board_setting (
|
|
||||||
id BIGINT NOT NULL,
|
|
||||||
tag_category_id BIGINT NULL COMMENT '이 게시판에서 사용 가능한 태그 카테고리',
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
INSERT IGNORE INTO board_setting (id, tag_category_id) VALUES (1, NULL);
|
|
||||||
|
|
||||||
-- 게시글 인라인 이미지 (DB 저장). 본문에는 /api/images/{id} URL 만 포함(base64 미사용).
|
-- 게시글 인라인 이미지 (DB 저장). 본문에는 /api/images/{id} URL 만 포함(base64 미사용).
|
||||||
CREATE TABLE IF NOT EXISTS board_image (
|
CREATE TABLE IF NOT EXISTS board_image (
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
@@ -95,3 +94,39 @@ CREATE TABLE IF NOT EXISTS comment (
|
|||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY idx_comment_post (post_id)
|
KEY idx_comment_post (post_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 댓글 신고 누적 블라인드 플래그 (멱등)
|
||||||
|
ALTER TABLE comment ADD COLUMN IF NOT EXISTS blocked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '신고 누적 블라인드';
|
||||||
|
|
||||||
|
-- 신고 (글/댓글). 회원당 대상별 1회. 5건 누적 시 블라인드(관리자만 열람).
|
||||||
|
CREATE TABLE IF NOT EXISTS report (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
target_type VARCHAR(10) NOT NULL COMMENT 'POST / COMMENT',
|
||||||
|
target_id BIGINT NOT NULL,
|
||||||
|
member_id BIGINT NOT NULL COMMENT '신고자',
|
||||||
|
reason VARCHAR(255) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_report (target_type, target_id, member_id),
|
||||||
|
KEY idx_report_target (target_type, target_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 게시글 추천/비추천 (회원당 1표, UP/DOWN). 같은 표 재요청 시 취소, 반대면 전환.
|
||||||
|
CREATE TABLE IF NOT EXISTS post_vote (
|
||||||
|
post_id BIGINT NOT NULL,
|
||||||
|
member_id BIGINT NOT NULL,
|
||||||
|
vote_type VARCHAR(4) NOT NULL COMMENT 'UP / DOWN',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (post_id, member_id),
|
||||||
|
KEY idx_post_vote_post (post_id, vote_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 댓글 추천/비추천 (회원당 1표)
|
||||||
|
CREATE TABLE IF NOT EXISTS comment_vote (
|
||||||
|
comment_id BIGINT NOT NULL,
|
||||||
|
member_id BIGINT NOT NULL,
|
||||||
|
vote_type VARCHAR(4) NOT NULL COMMENT 'UP / DOWN',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (comment_id, member_id),
|
||||||
|
KEY idx_comment_vote_comment (comment_id, vote_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|||||||
@@ -27,6 +27,51 @@ ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_google_id (google_id);
|
|||||||
-- 기존 구글 계정(provider=GOOGLE)의 sub 를 google_id 로 백필(멱등 — 이미 채워졌으면 아무 것도 안 함).
|
-- 기존 구글 계정(provider=GOOGLE)의 sub 를 google_id 로 백필(멱등 — 이미 채워졌으면 아무 것도 안 함).
|
||||||
UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_id IS NULL AND provider_id IS NOT NULL;
|
UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_id IS NULL AND provider_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등).
|
||||||
|
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role;
|
||||||
|
|
||||||
|
-- 프로필 사진. google_picture 는 구글 로그인 시 동기화되는 구글 아바타 URL,
|
||||||
|
-- profile_image 는 사용자가 직접 올린 커스텀 이미지(data URL). 표시 우선순위: profile_image > google_picture > 이니셜.
|
||||||
|
ALTER TABLE member ADD COLUMN IF NOT EXISTS google_picture VARCHAR(500) NULL COMMENT '구글 아바타 URL (로그인 시 동기화)' AFTER google_id;
|
||||||
|
ALTER TABLE member ADD COLUMN IF NOT EXISTS profile_image MEDIUMTEXT NULL COMMENT '사용자 지정 프로필 이미지(data URL)' AFTER google_picture;
|
||||||
|
|
||||||
|
-- 포인트(게시판 글/댓글 작성 보상). 기존 회원은 0 으로 시작 (멱등).
|
||||||
|
ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COMMENT '활동 포인트' AFTER plan;
|
||||||
|
|
||||||
|
-- 멤버십 만료일 (유료 구독). NULL = 만료 없음(관리자 영구 부여) 또는 무료. 만료 지나면 스케줄러가 FREE 로 강등.
|
||||||
|
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_expires_at DATETIME NULL COMMENT '유료 멤버십 만료일시' AFTER plan;
|
||||||
|
|
||||||
|
-- 구독 상품(premium_monthly 등) + 자동 갱신 여부 (정기결제 관리)
|
||||||
|
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_product VARCHAR(100) NULL COMMENT '구독 상품 ID' AFTER plan_expires_at;
|
||||||
|
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_auto_renew TINYINT(1) NOT NULL DEFAULT 0 COMMENT '자동 갱신(해지 시 0)' AFTER plan_product;
|
||||||
|
|
||||||
|
-- 인앱 결제 기록 (영수증 검증·중복 방지·감사)
|
||||||
|
CREATE TABLE IF NOT EXISTS iap_purchase (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
member_id BIGINT NOT NULL,
|
||||||
|
platform VARCHAR(20) NOT NULL COMMENT 'GOOGLE_PLAY / TEST',
|
||||||
|
product_id VARCHAR(100) NOT NULL COMMENT '상품 ID(premium_monthly 등)',
|
||||||
|
purchase_token VARCHAR(512) NOT NULL COMMENT 'Play 구매 토큰(또는 테스트 토큰)',
|
||||||
|
order_id VARCHAR(120) NULL,
|
||||||
|
status VARCHAR(20) NOT NULL COMMENT 'VERIFIED / FAILED',
|
||||||
|
expires_at DATETIME NULL COMMENT '이 결제로 부여된 만료일',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_iap_token (purchase_token),
|
||||||
|
KEY idx_iap_member (member_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 포인트 적립/차감 이력 (감사 + 일일 지급 한도 계산용).
|
||||||
|
CREATE TABLE IF NOT EXISTS point_history (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
member_id BIGINT NOT NULL,
|
||||||
|
amount INT NOT NULL COMMENT '증감 포인트(+/-)',
|
||||||
|
reason VARCHAR(30) NOT NULL COMMENT 'BOARD_WRITE 등',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_point_history_member_date (member_id, reason, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경.
|
-- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경.
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -47,6 +92,7 @@ CREATE TABLE IF NOT EXISTS auth_session (
|
|||||||
login_id VARCHAR(50) NULL,
|
login_id VARCHAR(50) NULL,
|
||||||
name VARCHAR(100) NULL,
|
name VARCHAR(100) NULL,
|
||||||
role VARCHAR(20) NULL,
|
role VARCHAR(20) NULL,
|
||||||
|
plan VARCHAR(20) NULL,
|
||||||
provider VARCHAR(20) NULL,
|
provider VARCHAR(20) NULL,
|
||||||
remember_me TINYINT(1) NOT NULL DEFAULT 0,
|
remember_me TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
@@ -54,3 +100,5 @@ CREATE TABLE IF NOT EXISTS auth_session (
|
|||||||
PRIMARY KEY (token),
|
PRIMARY KEY (token),
|
||||||
KEY idx_auth_session_expires (expires_at)
|
KEY idx_auth_session_expires (expires_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
-- 멤버십 플랜 백업 (기존 세션 테이블에도 멱등 추가).
|
||||||
|
ALTER TABLE auth_session ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NULL AFTER role;
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
<result property="installmentMonths" column="installment_months"/>
|
<result property="installmentMonths" column="installment_months"/>
|
||||||
<result property="pending" column="pending"/>
|
<result property="pending" column="pending"/>
|
||||||
<result property="notifKey" column="notif_key"/>
|
<result property="notifKey" column="notif_key"/>
|
||||||
|
<result property="currency" column="currency"/>
|
||||||
|
<result property="originalAmount" column="original_amount"/>
|
||||||
|
<result property="exchangeRate" column="exchange_rate"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
<result property="updatedAt" column="updated_at"/>
|
<result property="updatedAt" column="updated_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
@@ -32,6 +35,7 @@
|
|||||||
e.wallet_id, w.name AS wallet_name,
|
e.wallet_id, w.name AS wallet_name,
|
||||||
e.to_wallet_id, tw.name AS to_wallet_name,
|
e.to_wallet_id, tw.name AS to_wallet_name,
|
||||||
e.installment_months, e.pending, e.notif_key,
|
e.installment_months, e.pending, e.notif_key,
|
||||||
|
e.currency, e.original_amount, e.exchange_rate,
|
||||||
e.created_at, e.updated_at
|
e.created_at, e.updated_at
|
||||||
</sql>
|
</sql>
|
||||||
<sql id="entryJoins">
|
<sql id="entryJoins">
|
||||||
@@ -141,17 +145,20 @@
|
|||||||
useGeneratedKeys="true" keyProperty="id">
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo,
|
INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo,
|
||||||
wallet_id, to_wallet_id, installment_months, pending, notif_key,
|
wallet_id, to_wallet_id, installment_months, pending, notif_key,
|
||||||
|
currency, original_amount, exchange_rate,
|
||||||
created_at, updated_at)
|
created_at, updated_at)
|
||||||
VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo},
|
VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo},
|
||||||
#{walletId}, #{toWalletId}, #{installmentMonths},
|
#{walletId}, #{toWalletId}, #{installmentMonths},
|
||||||
COALESCE(#{pending}, 0), #{notifKey}, NOW(), NOW())
|
COALESCE(#{pending}, 0), #{notifKey},
|
||||||
|
#{currency}, #{originalAmount}, #{exchangeRate}, NOW(), NOW())
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="update" parameterType="com.sb.web.account.domain.AccountEntry">
|
<update id="update" parameterType="com.sb.web.account.domain.AccountEntry">
|
||||||
UPDATE account_entry
|
UPDATE account_entry
|
||||||
SET entry_date = #{entryDate}, type = #{type}, category = #{category},
|
SET entry_date = #{entryDate}, type = #{type}, category = #{category},
|
||||||
amount = #{amount}, memo = #{memo}, wallet_id = #{walletId}, to_wallet_id = #{toWalletId},
|
amount = #{amount}, memo = #{memo}, wallet_id = #{walletId}, to_wallet_id = #{toWalletId},
|
||||||
installment_months = #{installmentMonths}
|
installment_months = #{installmentMonths},
|
||||||
|
currency = #{currency}, original_amount = #{originalAmount}, exchange_rate = #{exchangeRate}
|
||||||
WHERE id = #{id} AND member_id = #{memberId}
|
WHERE id = #{id} AND member_id = #{memberId}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,16 @@
|
|||||||
|
|
||||||
<insert id="insert" parameterType="com.sb.web.auth.domain.AuthSession">
|
<insert id="insert" parameterType="com.sb.web.auth.domain.AuthSession">
|
||||||
INSERT INTO auth_session
|
INSERT INTO auth_session
|
||||||
(token, member_id, login_id, name, role, provider, remember_me, expires_at, created_at)
|
(token, member_id, login_id, name, role, plan, provider, remember_me, expires_at, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
(#{token}, #{memberId}, #{loginId}, #{name}, #{role}, #{provider},
|
(#{token}, #{memberId}, #{loginId}, #{name}, #{role}, #{plan}, #{provider},
|
||||||
#{rememberMe}, #{expiresAt}, NOW())
|
#{rememberMe}, #{expiresAt}, NOW())
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
expires_at = #{expiresAt}
|
expires_at = #{expiresAt}
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<select id="findByToken" resultType="com.sb.web.auth.domain.AuthSession">
|
<select id="findByToken" resultType="com.sb.web.auth.domain.AuthSession">
|
||||||
SELECT token, member_id, login_id, name, role, provider, remember_me, expires_at, created_at
|
SELECT token, member_id, login_id, name, role, plan, provider, remember_me, expires_at, created_at
|
||||||
FROM auth_session
|
FROM auth_session
|
||||||
WHERE token = #{token}
|
WHERE token = #{token}
|
||||||
</select>
|
</select>
|
||||||
@@ -27,6 +27,10 @@
|
|||||||
UPDATE auth_session SET name = #{name} WHERE token = #{token}
|
UPDATE auth_session SET name = #{name} WHERE token = #{token}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updatePlan">
|
||||||
|
UPDATE auth_session SET plan = #{plan} WHERE token = #{token}
|
||||||
|
</update>
|
||||||
|
|
||||||
<delete id="delete">
|
<delete id="delete">
|
||||||
DELETE FROM auth_session WHERE token = #{token}
|
DELETE FROM auth_session WHERE token = #{token}
|
||||||
</delete>
|
</delete>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|
||||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
|
||||||
<mapper namespace="com.sb.web.board.mapper.BoardSettingMapper">
|
|
||||||
|
|
||||||
<select id="findTagCategoryId" resultType="java.lang.Long">
|
|
||||||
SELECT tag_category_id FROM board_setting WHERE id = 1
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<update id="updateTagCategoryId">
|
|
||||||
UPDATE board_setting SET tag_category_id = #{categoryId} WHERE id = 1
|
|
||||||
</update>
|
|
||||||
|
|
||||||
</mapper>
|
|
||||||
@@ -14,15 +14,19 @@
|
|||||||
<result property="weekly" column="weekly"/>
|
<result property="weekly" column="weekly"/>
|
||||||
<result property="monthly" column="monthly"/>
|
<result property="monthly" column="monthly"/>
|
||||||
<result property="yearly" column="yearly"/>
|
<result property="yearly" column="yearly"/>
|
||||||
|
<result property="year" column="year"/>
|
||||||
|
<result property="month" column="month"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
<result property="updatedAt" column="updated_at"/>
|
<result property="updatedAt" column="updated_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<sql id="cols">id, member_id, category, fixed, base_unit, base_amount,
|
<sql id="cols">id, member_id, category, fixed, base_unit, base_amount,
|
||||||
daily, weekly, monthly, yearly, created_at, updated_at</sql>
|
daily, weekly, monthly, yearly, `year`, `month`, created_at, updated_at</sql>
|
||||||
|
|
||||||
<select id="findByMember" parameterType="long" resultMap="budgetResultMap">
|
<select id="findByMember" resultMap="budgetResultMap">
|
||||||
SELECT <include refid="cols"/> FROM budget WHERE member_id = #{memberId} ORDER BY category
|
SELECT <include refid="cols"/> FROM budget
|
||||||
|
WHERE member_id = #{memberId} AND `year` = #{year} AND `month` = #{month}
|
||||||
|
ORDER BY category
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findByIdAndMember" resultMap="budgetResultMap">
|
<select id="findByIdAndMember" resultMap="budgetResultMap">
|
||||||
@@ -32,15 +36,30 @@
|
|||||||
<select id="countByCategory" resultType="int">
|
<select id="countByCategory" resultType="int">
|
||||||
SELECT COUNT(*) FROM budget
|
SELECT COUNT(*) FROM budget
|
||||||
WHERE member_id = #{memberId} AND category = #{category}
|
WHERE member_id = #{memberId} AND category = #{category}
|
||||||
|
AND `year` = #{year} AND `month` = #{month}
|
||||||
<if test="excludeId != null">AND id != #{excludeId}</if>
|
<if test="excludeId != null">AND id != #{excludeId}</if>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insert" parameterType="com.sb.web.account.domain.Budget"
|
<insert id="insert" parameterType="com.sb.web.account.domain.Budget"
|
||||||
useGeneratedKeys="true" keyProperty="id">
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO budget (member_id, category, fixed, base_unit, base_amount,
|
INSERT INTO budget (member_id, category, fixed, base_unit, base_amount,
|
||||||
daily, weekly, monthly, yearly, created_at, updated_at)
|
daily, weekly, monthly, yearly, `year`, `month`, created_at, updated_at)
|
||||||
VALUES (#{memberId}, #{category}, #{fixed}, #{baseUnit}, #{baseAmount},
|
VALUES (#{memberId}, #{category}, #{fixed}, #{baseUnit}, #{baseAmount},
|
||||||
#{daily}, #{weekly}, #{monthly}, #{yearly}, NOW(), NOW())
|
#{daily}, #{weekly}, #{monthly}, #{yearly}, #{year}, #{month}, NOW(), NOW())
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 전월(또는 임의 월) 예산을 다른 월로 복사. 같은 분류는 덮어씀(ON DUPLICATE) -->
|
||||||
|
<insert id="copyMonth">
|
||||||
|
INSERT INTO budget (member_id, category, fixed, base_unit, base_amount,
|
||||||
|
daily, weekly, monthly, yearly, `year`, `month`, created_at, updated_at)
|
||||||
|
SELECT member_id, category, fixed, base_unit, base_amount,
|
||||||
|
daily, weekly, monthly, yearly, #{toYear}, #{toMonth}, NOW(), NOW()
|
||||||
|
FROM budget
|
||||||
|
WHERE member_id = #{memberId} AND `year` = #{fromYear} AND `month` = #{fromMonth}
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
fixed = VALUES(fixed), base_unit = VALUES(base_unit), base_amount = VALUES(base_amount),
|
||||||
|
daily = VALUES(daily), weekly = VALUES(weekly), monthly = VALUES(monthly),
|
||||||
|
yearly = VALUES(yearly), updated_at = NOW()
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="update" parameterType="com.sb.web.account.domain.Budget">
|
<update id="update" parameterType="com.sb.web.account.domain.Budget">
|
||||||
|
|||||||
@@ -8,21 +8,45 @@
|
|||||||
<result property="postId" column="post_id"/>
|
<result property="postId" column="post_id"/>
|
||||||
<result property="authorId" column="author_id"/>
|
<result property="authorId" column="author_id"/>
|
||||||
<result property="authorName" column="author_name"/>
|
<result property="authorName" column="author_name"/>
|
||||||
|
<result property="authorGooglePicture" column="author_google_picture"/>
|
||||||
|
<result property="authorProfileImage" column="author_profile_image"/>
|
||||||
<result property="content" column="content"/>
|
<result property="content" column="content"/>
|
||||||
|
<result property="blocked" column="blocked"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<select id="findByPostId" parameterType="long" resultMap="commentResultMap">
|
<select id="findByPostId" parameterType="long" resultMap="commentResultMap">
|
||||||
SELECT id, post_id, author_id, author_name, content, created_at
|
SELECT c.id, c.post_id, c.author_id, c.author_name, c.content, c.blocked, c.created_at,
|
||||||
FROM comment
|
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image
|
||||||
WHERE post_id = #{postId}
|
FROM comment c
|
||||||
ORDER BY id ASC
|
LEFT JOIN member m ON m.id = c.author_id
|
||||||
|
WHERE c.post_id = #{postId}
|
||||||
|
ORDER BY c.id ASC
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findById" parameterType="long" resultMap="commentResultMap">
|
<select id="findById" parameterType="long" resultMap="commentResultMap">
|
||||||
SELECT id, post_id, author_id, author_name, content, created_at
|
SELECT c.id, c.post_id, c.author_id, c.author_name, c.content, c.blocked, c.created_at,
|
||||||
FROM comment
|
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image
|
||||||
WHERE id = #{id}
|
FROM comment c
|
||||||
|
LEFT JOIN member m ON m.id = c.author_id
|
||||||
|
WHERE c.id = #{id}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="updateBlocked">
|
||||||
|
UPDATE comment SET blocked = #{blocked} WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="findMyPage" resultType="com.sb.web.board.dto.MyCommentResponse">
|
||||||
|
SELECT c.id, c.content, c.created_at, p.id AS post_id, p.title AS post_title, p.category
|
||||||
|
FROM comment c
|
||||||
|
JOIN post p ON p.id = c.post_id
|
||||||
|
WHERE c.author_id = #{memberId}
|
||||||
|
ORDER BY c.id DESC
|
||||||
|
LIMIT #{size} OFFSET #{offset}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countMyComments" parameterType="long" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM comment WHERE author_id = #{memberId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insert" parameterType="com.sb.web.board.domain.Comment"
|
<insert id="insert" parameterType="com.sb.web.board.domain.Comment"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.sb.web.billing.mapper.IapPurchaseMapper">
|
||||||
|
|
||||||
|
<resultMap id="iapResultMap" type="com.sb.web.billing.domain.IapPurchase">
|
||||||
|
<id property="id" column="id"/>
|
||||||
|
<result property="memberId" column="member_id"/>
|
||||||
|
<result property="platform" column="platform"/>
|
||||||
|
<result property="productId" column="product_id"/>
|
||||||
|
<result property="purchaseToken" column="purchase_token"/>
|
||||||
|
<result property="orderId" column="order_id"/>
|
||||||
|
<result property="status" column="status"/>
|
||||||
|
<result property="expiresAt" column="expires_at"/>
|
||||||
|
<result property="createdAt" column="created_at"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<insert id="insert" parameterType="com.sb.web.billing.domain.IapPurchase"
|
||||||
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
|
INSERT INTO iap_purchase (member_id, platform, product_id, purchase_token, order_id, status, expires_at, created_at)
|
||||||
|
VALUES (#{memberId}, #{platform}, #{productId}, #{purchaseToken}, #{orderId}, #{status}, #{expiresAt}, NOW())
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="findByToken" resultMap="iapResultMap">
|
||||||
|
SELECT id, member_id, platform, product_id, purchase_token, order_id, status, expires_at, created_at
|
||||||
|
FROM iap_purchase WHERE purchase_token = #{purchaseToken}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findLatestByMember" parameterType="long" resultMap="iapResultMap">
|
||||||
|
SELECT id, member_id, platform, product_id, purchase_token, order_id, status, expires_at, created_at
|
||||||
|
FROM iap_purchase
|
||||||
|
WHERE member_id = #{memberId} AND status = 'VERIFIED'
|
||||||
|
ORDER BY expires_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -12,22 +12,31 @@
|
|||||||
<result property="provider" column="provider"/>
|
<result property="provider" column="provider"/>
|
||||||
<result property="providerId" column="provider_id"/>
|
<result property="providerId" column="provider_id"/>
|
||||||
<result property="googleId" column="google_id"/>
|
<result property="googleId" column="google_id"/>
|
||||||
|
<result property="googlePicture" column="google_picture"/>
|
||||||
|
<result property="profileImage" column="profile_image"/>
|
||||||
<result property="role" column="role"/>
|
<result property="role" column="role"/>
|
||||||
|
<result property="plan" column="plan"/>
|
||||||
|
<result property="planExpiresAt" column="plan_expires_at"/>
|
||||||
|
<result property="planProduct" column="plan_product"/>
|
||||||
|
<result property="planAutoRenew" column="plan_auto_renew"/>
|
||||||
|
<result property="points" column="points"/>
|
||||||
<result property="status" column="status"/>
|
<result property="status" column="status"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
<result property="updatedAt" column="updated_at"/>
|
<result property="updatedAt" column="updated_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<sql id="columns">
|
<sql id="columns">
|
||||||
id, login_id, password, name, email, provider, provider_id, google_id, role, status, created_at, updated_at
|
id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, plan_expires_at, plan_product, plan_auto_renew, points, status, created_at, updated_at
|
||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
<select id="findById" parameterType="long" resultMap="memberResultMap">
|
<select id="findById" parameterType="long" resultMap="memberResultMap">
|
||||||
SELECT <include refid="columns"/> FROM member WHERE id = #{id}
|
SELECT <include refid="columns"/> FROM member WHERE id = #{id}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 관리자 목록: 무거운 profile_image(MEDIUMTEXT)·password 는 제외하고 가볍게 조회 -->
|
||||||
<select id="findAll" resultMap="memberResultMap">
|
<select id="findAll" resultMap="memberResultMap">
|
||||||
SELECT <include refid="columns"/> FROM member ORDER BY id
|
SELECT id, login_id, name, email, provider, provider_id, google_id, role, plan, points, status, created_at, updated_at
|
||||||
|
FROM member ORDER BY id
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findByLoginId" parameterType="string" resultMap="memberResultMap">
|
<select id="findByLoginId" parameterType="string" resultMap="memberResultMap">
|
||||||
@@ -65,9 +74,9 @@
|
|||||||
|
|
||||||
<insert id="insert" parameterType="com.sb.web.auth.domain.Member"
|
<insert id="insert" parameterType="com.sb.web.auth.domain.Member"
|
||||||
useGeneratedKeys="true" keyProperty="id">
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO member (login_id, password, name, email, provider, provider_id, google_id, role, status, created_at, updated_at)
|
INSERT INTO member (login_id, password, name, email, provider, provider_id, google_id, google_picture, role, status, created_at, updated_at)
|
||||||
VALUES (#{loginId}, #{password}, #{name}, #{email},
|
VALUES (#{loginId}, #{password}, #{name}, #{email},
|
||||||
#{provider}, #{providerId}, #{googleId}, #{role}, #{status}, NOW(), NOW())
|
#{provider}, #{providerId}, #{googleId}, #{googlePicture}, #{role}, #{status}, NOW(), NOW())
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="updatePassword">
|
<update id="updatePassword">
|
||||||
@@ -78,10 +87,43 @@
|
|||||||
UPDATE member SET name = #{name}, email = #{email}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET name = #{name}, email = #{email}, updated_at = NOW() WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updateGooglePicture">
|
||||||
|
UPDATE member SET google_picture = #{googlePicture}, updated_at = NOW() WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="updateProfileImage">
|
||||||
|
UPDATE member SET profile_image = #{profileImage}, updated_at = NOW() WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
<update id="updateRole">
|
<update id="updateRole">
|
||||||
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<!-- 관리자 수동 변경: 만료일/구독정보 없이(영구) 설정/해제 -->
|
||||||
|
<update id="updatePlan">
|
||||||
|
UPDATE member SET plan = #{plan}, plan_expires_at = NULL, plan_product = NULL, plan_auto_renew = 0,
|
||||||
|
updated_at = NOW() WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 결제로 멤버십 부여/갱신: plan + 만료일 + 구독상품 + 자동갱신 -->
|
||||||
|
<update id="updateMembership">
|
||||||
|
UPDATE member SET plan = #{plan}, plan_expires_at = #{expiresAt},
|
||||||
|
plan_product = #{product}, plan_auto_renew = #{autoRenew}, updated_at = NOW()
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 구독 해지/재개: 자동 갱신 플래그만 변경 (이용은 만료일까지 유지) -->
|
||||||
|
<update id="updateAutoRenew">
|
||||||
|
UPDATE member SET plan_auto_renew = #{autoRenew}, updated_at = NOW() WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 만료된 '해지(자동갱신 off)' 유료회원만 강등 (자동갱신 건은 마켓 갱신/RTDN 이 관리) -->
|
||||||
|
<update id="downgradeExpired">
|
||||||
|
UPDATE member SET plan = 'FREE', plan_product = NULL, plan_auto_renew = 0, updated_at = NOW()
|
||||||
|
WHERE plan = 'PREMIUM' AND plan_auto_renew = 0
|
||||||
|
AND plan_expires_at IS NOT NULL AND plan_expires_at < NOW()
|
||||||
|
</update>
|
||||||
|
|
||||||
<update id="updateStatus">
|
<update id="updateStatus">
|
||||||
UPDATE member SET status = #{status}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET status = #{status}, updated_at = NOW() WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.sb.web.point.mapper.PointMapper">
|
||||||
|
|
||||||
|
<select id="countTodayGrants" resultType="int">
|
||||||
|
SELECT COUNT(*) FROM point_history
|
||||||
|
WHERE member_id = #{memberId} AND reason = #{reason} AND created_at >= CURDATE()
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insertHistory">
|
||||||
|
INSERT INTO point_history (member_id, amount, reason, created_at)
|
||||||
|
VALUES (#{memberId}, #{amount}, #{reason}, NOW())
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="addPoints">
|
||||||
|
UPDATE member SET points = points + #{amount}, updated_at = NOW() WHERE id = #{memberId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="getPoints" parameterType="long" resultType="long">
|
||||||
|
SELECT points FROM member WHERE id = #{memberId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findHistory" resultType="com.sb.web.point.PointHistoryResponse">
|
||||||
|
SELECT amount, reason, created_at
|
||||||
|
FROM point_history
|
||||||
|
WHERE member_id = #{memberId}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT #{limit}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -35,25 +35,68 @@
|
|||||||
|
|
||||||
<select id="findPage" resultType="com.sb.web.board.dto.PostSummary">
|
<select id="findPage" resultType="com.sb.web.board.dto.PostSummary">
|
||||||
SELECT
|
SELECT
|
||||||
p.id, p.title, p.author_name, p.view_count, p.blocked, p.notice, p.created_at,
|
p.id, p.title, p.author_id, p.author_name, p.view_count, p.blocked, p.notice, p.created_at,
|
||||||
(SELECT COUNT(*) FROM comment c WHERE c.post_id = p.id) AS comment_count
|
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image,
|
||||||
|
(SELECT COUNT(*) FROM comment c WHERE c.post_id = p.id) AS comment_count,
|
||||||
|
(SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') AS up_count,
|
||||||
|
(SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'DOWN') AS down_count
|
||||||
FROM post p
|
FROM post p
|
||||||
|
LEFT JOIN member m ON m.id = p.author_id
|
||||||
<include refid="filter"/>
|
<include refid="filter"/>
|
||||||
ORDER BY p.notice DESC, p.id DESC
|
ORDER BY p.notice DESC, p.id DESC
|
||||||
LIMIT #{size} OFFSET #{offset}
|
LIMIT #{size} OFFSET #{offset}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 인기 글: 등록 #{hours}시간 이내 + 추천 #{minUp}건 이상, 추천 많은 순 #{limit}건 (상단 노출용) -->
|
||||||
|
<select id="findHotPosts" resultType="com.sb.web.board.dto.PostSummary">
|
||||||
|
SELECT
|
||||||
|
p.id, p.title, p.author_id, p.author_name, p.view_count, p.blocked, p.notice, p.created_at,
|
||||||
|
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image,
|
||||||
|
(SELECT COUNT(*) FROM comment c WHERE c.post_id = p.id) AS comment_count,
|
||||||
|
(SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') AS up_count,
|
||||||
|
(SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'DOWN') AS down_count
|
||||||
|
FROM post p
|
||||||
|
LEFT JOIN member m ON m.id = p.author_id
|
||||||
|
WHERE p.notice = 0
|
||||||
|
AND (#{category} IS NULL OR p.category = #{category})
|
||||||
|
AND p.created_at >= NOW() - INTERVAL #{hours} HOUR
|
||||||
|
AND (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') >= #{minUp}
|
||||||
|
ORDER BY (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') DESC, p.id DESC
|
||||||
|
LIMIT #{limit}
|
||||||
|
</select>
|
||||||
|
|
||||||
<select id="countPage" resultType="long">
|
<select id="countPage" resultType="long">
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM post p
|
FROM post p
|
||||||
<include refid="filter"/>
|
<include refid="filter"/>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 내 글 모아보기 -->
|
||||||
|
<select id="findMyPage" resultType="com.sb.web.board.dto.PostSummary">
|
||||||
|
SELECT
|
||||||
|
p.id, p.title, p.category, p.author_id, p.author_name, p.view_count, p.blocked, p.notice, p.created_at,
|
||||||
|
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image,
|
||||||
|
(SELECT COUNT(*) FROM comment c WHERE c.post_id = p.id) AS comment_count,
|
||||||
|
(SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') AS up_count,
|
||||||
|
(SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'DOWN') AS down_count
|
||||||
|
FROM post p
|
||||||
|
LEFT JOIN member m ON m.id = p.author_id
|
||||||
|
WHERE p.author_id = #{memberId}
|
||||||
|
ORDER BY p.id DESC
|
||||||
|
LIMIT #{size} OFFSET #{offset}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countMyPosts" parameterType="long" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM post WHERE author_id = #{memberId}
|
||||||
|
</select>
|
||||||
|
|
||||||
<select id="findById" parameterType="long" resultType="com.sb.web.board.domain.Post">
|
<select id="findById" parameterType="long" resultType="com.sb.web.board.domain.Post">
|
||||||
SELECT id, title, content, author_id, author_name, view_count,
|
SELECT p.id, p.title, p.content, p.author_id, p.author_name, p.view_count,
|
||||||
blocked, block_reason, notice, category, created_at, updated_at
|
p.blocked, p.block_reason, p.notice, p.category, p.created_at, p.updated_at,
|
||||||
FROM post
|
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image
|
||||||
WHERE id = #{id}
|
FROM post p
|
||||||
|
LEFT JOIN member m ON m.id = p.author_id
|
||||||
|
WHERE p.id = #{id}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insert" parameterType="com.sb.web.board.domain.Post"
|
<insert id="insert" parameterType="com.sb.web.board.domain.Post"
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.sb.web.board.mapper.ReportMapper">
|
||||||
|
|
||||||
|
<insert id="insertIgnore">
|
||||||
|
INSERT IGNORE INTO report (target_type, target_id, member_id, reason, created_at)
|
||||||
|
VALUES (#{targetType}, #{targetId}, #{memberId}, #{reason}, NOW())
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="countByTarget" resultType="int">
|
||||||
|
SELECT COUNT(*) FROM report WHERE target_type = #{targetType} AND target_id = #{targetId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<delete id="deleteByTarget">
|
||||||
|
DELETE FROM report WHERE target_type = #{targetType} AND target_id = #{targetId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<delete id="deleteByMember">
|
||||||
|
DELETE FROM report WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.sb.web.board.mapper.TagCategoryBoardMapper">
|
||||||
|
|
||||||
|
<select id="findBoards" parameterType="long" resultType="string">
|
||||||
|
SELECT board_category FROM tag_category_board WHERE tag_category_id = #{categoryId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<delete id="deleteByCategory" parameterType="long">
|
||||||
|
DELETE FROM tag_category_board WHERE tag_category_id = #{categoryId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<insert id="insert">
|
||||||
|
INSERT INTO tag_category_board (tag_category_id, board_category)
|
||||||
|
VALUES (#{categoryId}, #{board})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.sb.web.board.mapper.VoteMapper">
|
||||||
|
|
||||||
|
<!-- ===== 게시글 ===== -->
|
||||||
|
<select id="findPostVote" resultType="string">
|
||||||
|
SELECT vote_type FROM post_vote WHERE post_id = #{postId} AND member_id = #{memberId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="upsertPostVote">
|
||||||
|
INSERT INTO post_vote (post_id, member_id, vote_type, created_at)
|
||||||
|
VALUES (#{postId}, #{memberId}, #{type}, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE vote_type = #{type}, created_at = NOW()
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<delete id="deletePostVote">
|
||||||
|
DELETE FROM post_vote WHERE post_id = #{postId} AND member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<select id="countPostVotes" resultType="int">
|
||||||
|
SELECT COUNT(*) FROM post_vote WHERE post_id = #{postId} AND vote_type = #{type}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findPostRecommenders" parameterType="long" resultType="com.sb.web.board.dto.Recommender">
|
||||||
|
SELECT m.id AS member_id, m.name, m.google_picture, m.profile_image
|
||||||
|
FROM post_vote v
|
||||||
|
JOIN member m ON m.id = v.member_id
|
||||||
|
WHERE v.post_id = #{postId} AND v.vote_type = 'UP'
|
||||||
|
ORDER BY v.created_at DESC, v.member_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<delete id="deletePostVotesByPost" parameterType="long">
|
||||||
|
DELETE FROM post_vote WHERE post_id = #{postId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<!-- ===== 댓글 ===== -->
|
||||||
|
<select id="findCommentVote" resultType="string">
|
||||||
|
SELECT vote_type FROM comment_vote WHERE comment_id = #{commentId} AND member_id = #{memberId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="upsertCommentVote">
|
||||||
|
INSERT INTO comment_vote (comment_id, member_id, vote_type, created_at)
|
||||||
|
VALUES (#{commentId}, #{memberId}, #{type}, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE vote_type = #{type}, created_at = NOW()
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<delete id="deleteCommentVote">
|
||||||
|
DELETE FROM comment_vote WHERE comment_id = #{commentId} AND member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<select id="countCommentVotes" resultType="int">
|
||||||
|
SELECT COUNT(*) FROM comment_vote WHERE comment_id = #{commentId} AND vote_type = #{type}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findCommentVoteSummary" resultType="com.sb.web.board.dto.CommentVoteSummary">
|
||||||
|
SELECT cv.comment_id,
|
||||||
|
SUM(cv.vote_type = 'UP') AS up_count,
|
||||||
|
SUM(cv.vote_type = 'DOWN') AS down_count,
|
||||||
|
MAX(CASE WHEN cv.member_id = #{memberId} THEN cv.vote_type END) AS my_vote
|
||||||
|
FROM comment_vote cv
|
||||||
|
JOIN comment c ON c.id = cv.comment_id
|
||||||
|
WHERE c.post_id = #{postId}
|
||||||
|
GROUP BY cv.comment_id
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<delete id="deleteCommentVotesByComment" parameterType="long">
|
||||||
|
DELETE FROM comment_vote WHERE comment_id = #{commentId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<delete id="deleteCommentVotesByPost" parameterType="long">
|
||||||
|
DELETE FROM comment_vote WHERE comment_id IN (SELECT id FROM comment WHERE post_id = #{postId})
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -15,13 +15,20 @@
|
|||||||
<result property="openingBalance" column="opening_balance"/>
|
<result property="openingBalance" column="opening_balance"/>
|
||||||
<result property="openingDate" column="opening_date"/>
|
<result property="openingDate" column="opening_date"/>
|
||||||
<result property="currentValue" column="current_value"/>
|
<result property="currentValue" column="current_value"/>
|
||||||
|
<result property="loanAmount" column="loan_amount"/>
|
||||||
|
<result property="loanRate" column="loan_rate"/>
|
||||||
|
<result property="loanMethod" column="loan_method"/>
|
||||||
|
<result property="loanMonths" column="loan_months"/>
|
||||||
|
<result property="loanStart" column="loan_start"/>
|
||||||
<result property="sortOrder" column="sort_order"/>
|
<result property="sortOrder" column="sort_order"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
<result property="updatedAt" column="updated_at"/>
|
<result property="updatedAt" column="updated_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<sql id="cols">id, member_id, type, name, issuer, account_number, card_type,
|
<sql id="cols">id, member_id, type, name, issuer, account_number, card_type,
|
||||||
opening_balance, opening_date, current_value, sort_order, created_at, updated_at</sql>
|
opening_balance, opening_date, current_value,
|
||||||
|
loan_amount, loan_rate, loan_method, loan_months, loan_start,
|
||||||
|
sort_order, created_at, updated_at</sql>
|
||||||
|
|
||||||
<select id="findByMember" parameterType="long" resultMap="walletResultMap">
|
<select id="findByMember" parameterType="long" resultMap="walletResultMap">
|
||||||
SELECT <include refid="cols"/> FROM wallet
|
SELECT <include refid="cols"/> FROM wallet
|
||||||
@@ -37,10 +44,14 @@
|
|||||||
<insert id="insert" parameterType="com.sb.web.account.domain.Wallet"
|
<insert id="insert" parameterType="com.sb.web.account.domain.Wallet"
|
||||||
useGeneratedKeys="true" keyProperty="id">
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO wallet (member_id, type, name, issuer, account_number, card_type,
|
INSERT INTO wallet (member_id, type, name, issuer, account_number, card_type,
|
||||||
opening_balance, opening_date, current_value, sort_order, created_at, updated_at)
|
opening_balance, opening_date, current_value,
|
||||||
|
loan_amount, loan_rate, loan_method, loan_months, loan_start,
|
||||||
|
sort_order, created_at, updated_at)
|
||||||
VALUES (#{memberId}, #{type}, #{name}, #{issuer},
|
VALUES (#{memberId}, #{type}, #{name}, #{issuer},
|
||||||
#{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler}, #{cardType},
|
#{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler}, #{cardType},
|
||||||
#{openingBalance}, #{openingDate}, #{currentValue}, #{sortOrder}, NOW(), NOW())
|
#{openingBalance}, #{openingDate}, #{currentValue},
|
||||||
|
#{loanAmount}, #{loanRate}, #{loanMethod}, #{loanMonths}, #{loanStart},
|
||||||
|
#{sortOrder}, NOW(), NOW())
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="update" parameterType="com.sb.web.account.domain.Wallet">
|
<update id="update" parameterType="com.sb.web.account.domain.Wallet">
|
||||||
@@ -49,7 +60,10 @@
|
|||||||
account_number = #{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler},
|
account_number = #{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler},
|
||||||
card_type = #{cardType},
|
card_type = #{cardType},
|
||||||
opening_balance = #{openingBalance}, opening_date = #{openingDate},
|
opening_balance = #{openingBalance}, opening_date = #{openingDate},
|
||||||
current_value = #{currentValue}
|
current_value = #{currentValue},
|
||||||
|
loan_amount = #{loanAmount},
|
||||||
|
loan_rate = #{loanRate}, loan_method = #{loanMethod},
|
||||||
|
loan_months = #{loanMonths}, loan_start = #{loanStart}
|
||||||
WHERE id = #{id} AND member_id = #{memberId}
|
WHERE id = #{id} AND member_id = #{memberId}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.sb.web.auth.mapper.WithdrawMapper">
|
||||||
|
|
||||||
|
<!-- 내가 누른 표 -->
|
||||||
|
<delete id="deletePostVotesByMember">
|
||||||
|
DELETE FROM post_vote WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteCommentVotesByMember">
|
||||||
|
DELETE FROM comment_vote WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<!-- 내 글에 달린 것들 -->
|
||||||
|
<delete id="deleteCommentVotesOnMyPosts">
|
||||||
|
DELETE cv FROM comment_vote cv
|
||||||
|
JOIN comment c ON c.id = cv.comment_id
|
||||||
|
JOIN post p ON p.id = c.post_id
|
||||||
|
WHERE p.author_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deletePostVotesOnMyPosts">
|
||||||
|
DELETE pv FROM post_vote pv
|
||||||
|
JOIN post p ON p.id = pv.post_id
|
||||||
|
WHERE p.author_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deletePostTagsOnMyPosts">
|
||||||
|
DELETE pt FROM post_tag pt
|
||||||
|
JOIN post p ON p.id = pt.post_id
|
||||||
|
WHERE p.author_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteCommentsOnMyPosts">
|
||||||
|
DELETE c FROM comment c
|
||||||
|
JOIN post p ON p.id = c.post_id
|
||||||
|
WHERE p.author_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<!-- 내 댓글과 그 표 -->
|
||||||
|
<delete id="deleteCommentVotesOnMyComments">
|
||||||
|
DELETE cv FROM comment_vote cv
|
||||||
|
JOIN comment c ON c.id = cv.comment_id
|
||||||
|
WHERE c.author_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteCommentsByAuthor">
|
||||||
|
DELETE FROM comment WHERE author_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<!-- 내 글 / 업로드 이미지 -->
|
||||||
|
<delete id="deletePostsByAuthor">
|
||||||
|
DELETE FROM post WHERE author_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteBoardImagesByMember">
|
||||||
|
DELETE FROM board_image WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<!-- 신고 / 포인트 / 결제 / 세션 -->
|
||||||
|
<delete id="deleteReportsByMember">
|
||||||
|
DELETE FROM report WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deletePointHistory">
|
||||||
|
DELETE FROM point_history WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteIapPurchases">
|
||||||
|
DELETE FROM iap_purchase WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteAuthSessions">
|
||||||
|
DELETE FROM auth_session WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -38,6 +38,7 @@ class AuthControllerWebMvcTest {
|
|||||||
@Autowired MockMvc mvc;
|
@Autowired MockMvc mvc;
|
||||||
@MockitoBean AuthService authService;
|
@MockitoBean AuthService authService;
|
||||||
@MockitoBean AppSettingService appSettingService;
|
@MockitoBean AppSettingService appSettingService;
|
||||||
|
@MockitoBean com.sb.web.point.PointService pointService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @MapperScan 매퍼 빈들이 SqlSessionTemplate 을 요구한다(웹 슬라이스엔 DataSource 없음).
|
* @MapperScan 매퍼 빈들이 SqlSessionTemplate 을 요구한다(웹 슬라이스엔 DataSource 없음).
|
||||||
|
|||||||
@@ -47,12 +47,15 @@ class AuthServiceTest {
|
|||||||
@Mock ValueOperations<String, Object> valueOps;
|
@Mock ValueOperations<String, Object> valueOps;
|
||||||
@Mock AppSettingService appSettingService;
|
@Mock AppSettingService appSettingService;
|
||||||
@Mock AuthSessionMapper authSessionMapper;
|
@Mock AuthSessionMapper authSessionMapper;
|
||||||
|
@Mock com.sb.web.auth.mapper.WithdrawMapper withdrawMapper;
|
||||||
|
@Mock com.sb.web.account.mapper.BackupMapper backupMapper;
|
||||||
|
|
||||||
AuthService service;
|
AuthService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new AuthService(memberMapper, passwordEncoder, redisTemplate, appSettingService, authSessionMapper);
|
service = new AuthService(memberMapper, passwordEncoder, redisTemplate, appSettingService, authSessionMapper,
|
||||||
|
withdrawMapper, backupMapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Member localMember() {
|
private Member localMember() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.sb.web.common.config;
|
|||||||
|
|
||||||
import com.sb.web.auth.web.AdminInterceptor;
|
import com.sb.web.auth.web.AdminInterceptor;
|
||||||
import com.sb.web.auth.web.AuthInterceptor;
|
import com.sb.web.auth.web.AuthInterceptor;
|
||||||
|
import com.sb.web.auth.web.PremiumInterceptor;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
@@ -33,7 +34,8 @@ class WebConfigTest {
|
|||||||
when(registry.addInterceptor(any())).thenReturn(registration);
|
when(registry.addInterceptor(any())).thenReturn(registration);
|
||||||
when(registration.addPathPatterns(any(String[].class))).thenReturn(registration);
|
when(registration.addPathPatterns(any(String[].class))).thenReturn(registration);
|
||||||
|
|
||||||
new WebConfig(mock(AuthInterceptor.class), mock(AdminInterceptor.class)).addInterceptors(registry);
|
new WebConfig(mock(AuthInterceptor.class), mock(AdminInterceptor.class), mock(PremiumInterceptor.class))
|
||||||
|
.addInterceptors(registry);
|
||||||
|
|
||||||
ArgumentCaptor<String[]> captor = ArgumentCaptor.forClass(String[].class);
|
ArgumentCaptor<String[]> captor = ArgumentCaptor.forClass(String[].class);
|
||||||
verify(registration, atLeastOnce()).addPathPatterns(captor.capture());
|
verify(registration, atLeastOnce()).addPathPatterns(captor.capture());
|
||||||
@@ -46,6 +48,14 @@ class WebConfigTest {
|
|||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
"/api/auth/password",
|
"/api/auth/password",
|
||||||
"/api/auth/verify-password",
|
"/api/auth/verify-password",
|
||||||
"/api/auth/profile");
|
"/api/auth/profile",
|
||||||
|
"/api/auth/profile-image");
|
||||||
|
// 유료(PREMIUM) 전용 경로가 premiumInterceptor 에 등록되는지 회귀 가드
|
||||||
|
assertThat(allPatterns).contains(
|
||||||
|
"/api/account/stats",
|
||||||
|
"/api/account/budgets/**",
|
||||||
|
"/api/account/recurrings/**",
|
||||||
|
"/api/account/tags",
|
||||||
|
"/api/account/restore");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user