Compare commits

..

10 Commits

Author SHA1 Message Date
ByungCheol 947c7a1f68 feat(account): 대출 계좌 loan_amount/loan_rate/loan_method/loan_months/loan_start 필드 추가
CI / build (push) Failing after 15m38s
- wallet 테이블: loan_amount(실행금액), loan_rate(연이자율%), loan_method(상환방식),
  loan_months(기간), loan_start(시작일) ALTER TABLE 추가
- Wallet 도메인/DTO/Mapper/Service 동기화
- WalletResponse.from()에 대출 필드 반영

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 00:45:42 +09:00
ByungCheol 24e235404b feat: 월별 예산 + 전월/다음달 복사
CI / build (push) Failing after 15m52s
- budget 테이블에 year/month 추가, (member,category)→(member,category,year,month) 유니크 교체
  기존 상시 예산은 현재 월로 1회 이관
- 예산 조회/생성/현황/기간차트를 월별로(findByMember에 year/month)
- copyMonth: 다른 월 예산을 대상 월로 복사(같은 분류 덮어씀) + POST /budgets/copy
- 백업 복구 시 예산은 현재 월로 등록

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:35:32 +09:00
ByungCheol 10f51976d9 feat: 외화 결제 입력 — 통화+환율→원화 환산, 원본 보존 + 환율 API
CI / build (push) Failing after 14m19s
- account_entry에 currency/original_amount/exchange_rate 컬럼 추가(멱등 ALTER)
  amount는 환산 원화(표준값) 유지 → 통계·예산 무변경
- AccountEntry/Request/Response·매퍼·서비스에 외화 필드 전달
- FxService(open.er-api.com 무료·무인증, 통화별 일자 캐시) + FxController
  GET /api/fx/rate?from=USD&to=KRW → { from, to, rate }
- WebConfig: /api/fx/** 인증 보호
- (.gitignore: 로컬 시드 스크립트 제외 규칙)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:37:04 +09:00
ByungCheol b969ba6104 feat: 내 글/댓글 모아보기 API
CI / build (push) Successful in 59s
- GET /board/my/posts, /board/my/comments (페이징)
- PostSummary 에 category 추가, MyCommentResponse(글 제목·카테고리 포함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:25:48 +09:00
ByungCheol ddcd6f4335 feat: 신고 블라인드 관리자 해제 + 해제 시 신고기록 초기화
CI / build (push) Failing after 14m33s
- POST /board/comments/{id}/unblock (관리자) — 댓글 블라인드 해제
- 글/댓글 블라인드 해제 시 신고 기록 삭제(재블라인드 방지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:16:56 +09:00
ByungCheol ba75613e1c feat: 게시판 신고 — 누적 5건 시 블라인드(관리자만 열람)
CI / build (push) Failing after 11m17s
- report 테이블(회원당 대상별 1회), comment.blocked 컬럼
- POST /board/posts|comments/{id}/report, 본인 글/댓글 신고 불가
- 글: 5건이면 blocked(기존 열람제한 재사용) / 댓글: blocked → 비관리자 본문 숨김
- 삭제·탈퇴 시 신고 정리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:05:10 +09:00
ByungCheol a47b8405f4 feat(billing): 구매 복원 + RTDN 수신 스캐폴드 + 만료 강등 정합성
CI / build (push) Failing after 12m15s
- 만료 강등 스케줄러: 해지(자동갱신 off) 건만 강등(자동갱신은 마켓/RTDN 관리)
- POST /api/billing/restore: 최근 결제 기준 멤버십 복원(기기변경·재설치)
- POST /api/billing/google/rtdn: Google Play 실시간 알림 수신 스캐폴드(비인증, 로깅)
- 실연동 지점(영수증 검증·RTDN 처리)은 TODO 로 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:29:15 +09:00
ByungCheol 8142b8b518 feat: 회원 탈퇴(자기 계정 삭제) — Play 정책 대응
CI / build (push) Failing after 15m7s
- DELETE /api/auth/me: 본인 계정과 모든 데이터 영구 삭제
- 게시판(글/댓글/추천/이미지)·가계부 전체·포인트·결제기록·세션 → 회원 순 삭제
- WithdrawMapper(게시판/포인트/결제/세션) + BackupMapper(가계부) 재사용
- AuthServiceTest 생성자 보강

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:56:23 +09:00
ByungCheol be15f5b85d feat: 정기결제 관리 — 구독 현황/해지/재개/플랜변경 백엔드
CI / build (push) Failing after 12m10s
- member.plan_product, plan_auto_renew 컬럼(구독 상품·자동갱신)
- GET /api/billing/subscription: 플랜·만료일·다음결제일·자동갱신
- POST /api/billing/cancel: 자동갱신 중단(만료일까지 이용), /resume: 재개
- verify 시 상품·자동갱신 기록, 만료 강등·관리자 변경 시 구독정보 정리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:04:20 +09:00
ByungCheol a4b6c3fd2d fix: 목록 태그 필터도 게시판별로 — /board/tags?category 로 매핑된 그룹 태그만
CI / build (push) Failing after 12m6s
- 게시판 목록 화면의 태그 필터가 전체 태그를 보여주던 문제(매핑 무시) 수정
- 글쓰기와 동일한 listWritableGroups 기준으로 일관 적용

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:44:24 +09:00
48 changed files with 940 additions and 42 deletions
+4
View File
@@ -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
@@ -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,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; // 실패 시 직전 캐시라도
}
}
}
@@ -80,6 +80,15 @@ 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") @GetMapping("/points")
public java.util.Map<String, Long> points( public java.util.Map<String, Long> points(
@@ -31,6 +31,8 @@ public class Member implements Serializable {
private String role; // USER / ADMIN private String role; // USER / ADMIN
private String plan; // FREE / PREMIUM (멤버십) private String plan; // FREE / PREMIUM (멤버십)
private LocalDateTime planExpiresAt; // 유료 멤버십 만료일 (NULL=만료없음/무료) private LocalDateTime planExpiresAt; // 유료 멤버십 만료일 (NULL=만료없음/무료)
private String planProduct; // 구독 상품 ID (premium_monthly 등)
private Boolean planAutoRenew; // 자동 갱신 여부 (해지 시 false)
private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상) private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상)
private String status; // ACTIVE / SUSPENDED / WITHDRAWN private String status; // ACTIVE / SUSPENDED / WITHDRAWN
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -19,6 +19,8 @@ public class MemberResponse {
private String role; private String role;
private String plan; // FREE / PREMIUM private String plan; // FREE / PREMIUM
private java.time.LocalDateTime planExpiresAt; // 유료 멤버십 만료일 private java.time.LocalDateTime planExpiresAt; // 유료 멤버십 만료일
private String planProduct; // 구독 상품 ID
private Boolean planAutoRenew; // 자동 갱신 여부
private Long points; // 활동 포인트 private Long points; // 활동 포인트
private String googlePicture; // 구글 아바타 URL private String googlePicture; // 구글 아바타 URL
private String profileImage; // 사용자 지정 프로필 이미지(data URL) private String profileImage; // 사용자 지정 프로필 이미지(data URL)
@@ -33,6 +35,8 @@ public class MemberResponse {
.role(m.getRole()) .role(m.getRole())
.plan(m.getPlan()) .plan(m.getPlan())
.planExpiresAt(m.getPlanExpiresAt()) .planExpiresAt(m.getPlanExpiresAt())
.planProduct(m.getPlanProduct())
.planAutoRenew(m.getPlanAutoRenew())
.points(m.getPoints()) .points(m.getPoints())
.googlePicture(m.getGooglePicture()) .googlePicture(m.getGooglePicture())
.profileImage(m.getProfileImage()) .profileImage(m.getProfileImage())
@@ -50,9 +50,13 @@ public interface MemberMapper {
int updatePlan(@Param("id") Long id, @Param("plan") String plan); int updatePlan(@Param("id") Long id, @Param("plan") String plan);
/** 결제로 멤버십 부여/갱신 (plan + 만료일) */ /** 결제로 멤버십 부여/갱신 (plan + 만료일 + 구독상품 + 자동갱신) */
int updateMembership(@Param("id") Long id, @Param("plan") String plan, int updateMembership(@Param("id") Long id, @Param("plan") String plan,
@Param("expiresAt") java.time.LocalDateTime expiresAt); @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 로 강등 (스케줄러). 강등된 건수 반환 */ /** 만료된 유료회원을 FREE 로 강등 (스케줄러). 강등된 건수 반환 */
int downgradeExpired(); int downgradeExpired();
@@ -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:}")
@@ -289,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() {
@@ -4,6 +4,7 @@ import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor; import com.sb.web.auth.web.AuthInterceptor;
import com.sb.web.billing.dto.MembershipResponse; import com.sb.web.billing.dto.MembershipResponse;
import com.sb.web.billing.dto.ProductResponse; 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.dto.VerifyRequest;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -36,4 +37,43 @@ public class BillingController {
HttpServletRequest request) { HttpServletRequest request) {
return billingService.verifyGoogle(current.getId(), AuthInterceptor.resolveToken(request), req); 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();
}
} }
@@ -6,6 +6,7 @@ import com.sb.web.auth.service.AuthService;
import com.sb.web.billing.domain.IapPurchase; import com.sb.web.billing.domain.IapPurchase;
import com.sb.web.billing.dto.MembershipResponse; import com.sb.web.billing.dto.MembershipResponse;
import com.sb.web.billing.dto.ProductResponse; 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.dto.VerifyRequest;
import com.sb.web.billing.mapper.IapPurchaseMapper; import com.sb.web.billing.mapper.IapPurchaseMapper;
import com.sb.web.common.exception.ApiException; import com.sb.web.common.exception.ApiException;
@@ -86,7 +87,7 @@ public class BillingService {
? member.getPlanExpiresAt() : now; ? member.getPlanExpiresAt() : now;
LocalDateTime expires = base.plusMonths(product.getMonths()); LocalDateTime expires = base.plusMonths(product.getMonths());
memberMapper.updateMembership(memberId, "PREMIUM", expires); memberMapper.updateMembership(memberId, "PREMIUM", expires, req.getProductId(), true);
purchaseMapper.insert(IapPurchase.builder() purchaseMapper.insert(IapPurchase.builder()
.memberId(memberId).platform(platform).productId(req.getProductId()) .memberId(memberId).platform(platform).productId(req.getProductId())
.purchaseToken(token).orderId(req.getOrderId()) .purchaseToken(token).orderId(req.getOrderId())
@@ -101,6 +102,96 @@ public class BillingService {
return MembershipResponse.of(member); 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) { private Member mustFind(Long memberId) {
Member m = memberMapper.findById(memberId); Member m = memberMapper.findById(memberId);
if (m == null) { if (m == null) {
@@ -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)
}
@@ -14,4 +14,7 @@ public interface IapPurchaseMapper {
/** 구매 토큰으로 기존 처리 여부 조회 (중복 결제 방지) */ /** 구매 토큰으로 기존 처리 여부 조회 (중복 결제 방지) */
IapPurchase findByToken(@Param("purchaseToken") String purchaseToken); IapPurchase findByToken(@Param("purchaseToken") String purchaseToken);
/** 회원의 가장 최근(만료일 먼) 결제 — 구매 복원용 */
IapPurchase findLatestByMember(@Param("memberId") Long memberId);
} }
@@ -53,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,
@@ -120,9 +138,17 @@ 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) {
return boardService.allTags(); if (category == null || category.isBlank()) {
return boardService.allTags();
}
return tagService.listWritableGroups(category).stream()
.flatMap(g -> g.getTags().stream())
.map(com.sb.web.board.dto.TagResponse::getName)
.distinct()
.toList();
} }
/** 글 작성 시 선택 가능한 태그 (해당 게시판에 매핑된 그룹만) */ /** 글 작성 시 선택 가능한 태그 (해당 게시판에 매핑된 그룹만) */
@@ -170,4 +196,31 @@ public class BoardController {
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return boardService.voteComment(commentId, req.getType(), 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();
}
} }
@@ -24,5 +24,6 @@ public class Comment implements Serializable {
private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함) private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함)
private String authorProfileImage; // 조회 시 member JOIN (저장 안 함) private String authorProfileImage; // 조회 시 member JOIN (저장 안 함)
private String content; private String content;
private Boolean blocked; // 신고 누적 블라인드
private LocalDateTime createdAt; private LocalDateTime createdAt;
} }
@@ -24,6 +24,7 @@ public class CommentResponse {
private Integer downCount; // 비추천 수 private Integer downCount; // 비추천 수
private String myVote; // 현재 사용자의 투표: UP / DOWN / null private String myVote; // 현재 사용자의 투표: UP / DOWN / null
private Boolean hot; // 인기 댓글(추천 50+ & 1일 이내) 상단 노출 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()
@@ -33,6 +34,7 @@ public class CommentResponse {
.authorGooglePicture(c.getAuthorGooglePicture()) .authorGooglePicture(c.getAuthorGooglePicture())
.authorProfileImage(c.getAuthorProfileImage()) .authorProfileImage(c.getAuthorProfileImage())
.content(c.getContent()) .content(c.getContent())
.blocked(Boolean.TRUE.equals(c.getBlocked()))
.createdAt(c.getCreatedAt()) .createdAt(c.getCreatedAt())
.upCount(0) .upCount(0)
.downCount(0) .downCount(0)
@@ -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;
}
@@ -13,6 +13,7 @@ public class PostSummary {
private Long id; private Long id;
private String title; private String title;
private String category; // 내 글 모아보기 등 교차 게시판 링크용
private Long authorId; private Long authorId;
private String authorName; private String authorName;
private String authorGooglePicture; private String authorGooglePicture;
@@ -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);
} }
@@ -28,6 +28,12 @@ public interface PostMapper {
@Param("minUp") int minUp, @Param("minUp") int minUp,
@Param("limit") int limit); @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);
}
@@ -14,6 +14,7 @@ import com.sb.web.board.dto.Recommender;
import com.sb.web.board.dto.VoteResponse; 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.board.mapper.VoteMapper;
import com.sb.web.common.exception.ApiException; import com.sb.web.common.exception.ApiException;
@@ -41,6 +42,7 @@ public class BoardService {
private final TagMapper tagMapper; private final TagMapper tagMapper;
private final CommentMapper commentMapper; private final CommentMapper commentMapper;
private final VoteMapper voteMapper; private final VoteMapper voteMapper;
private final ReportMapper reportMapper;
private final PointService pointService; private final PointService pointService;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
@@ -48,6 +50,10 @@ public class BoardService {
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 UP = "UP";
private static final String DOWN = "DOWN"; 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건 노출 // 인기 글/댓글: 등록 1일 이내 + 추천 50건 이상이면 상단에 최대 3건 노출
private static final int HOT_WITHIN_HOURS = 24; private static final int HOT_WITHIN_HOURS = 24;
@@ -83,6 +89,22 @@ public class BoardService {
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";
} }
@@ -119,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);
} }
/** 게시글 공지 설정/해제 (관리자 전용) — 목록 최상단 고정 */ /** 게시글 공지 설정/해제 (관리자 전용) — 목록 최상단 고정 */
@@ -185,6 +223,7 @@ public class BoardService {
// 투표 정리(댓글 투표는 댓글 삭제 전에 — 서브쿼리가 comment 참조) // 투표 정리(댓글 투표는 댓글 삭제 전에 — 서브쿼리가 comment 참조)
voteMapper.deleteCommentVotesByPost(id); voteMapper.deleteCommentVotesByPost(id);
voteMapper.deletePostVotesByPost(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);
@@ -217,9 +256,43 @@ public class BoardService {
} }
assertCanModify(comment.getAuthorId(), user); assertCanModify(comment.getAuthorId(), user);
voteMapper.deleteCommentVotesByComment(commentId); 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);
}
}
/* ===================== 추천/비추천 ===================== */ /* ===================== 추천/비추천 ===================== */
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */ /** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
@@ -301,6 +374,13 @@ public class BoardService {
} }
return cr; return cr;
}).toList()); }).toList());
// 신고 누적 블라인드 댓글: 관리자가 아니면 본문 숨김
boolean viewerAdmin = isAdmin(viewer);
if (!viewerAdmin) {
comments.forEach(cr -> {
if (Boolean.TRUE.equals(cr.getBlocked())) cr.setContent(null);
});
}
comments = orderWithHot(comments); comments = orderWithHot(comments);
PostDetail detail = PostDetail.of(post, tags, comments); PostDetail detail = PostDetail.of(post, tags, comments);
@@ -28,7 +28,9 @@ public class WebConfig implements WebMvcConfigurer {
.addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history", .addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history",
"/api/auth/logout", "/api/auth/password", "/api/auth/logout", "/api/auth/password",
"/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image", "/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image",
"/api/board/**", "/api/admin/**", "/api/account/**", "/api/billing/**"); "/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/**");
+18
View File
@@ -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,
+16
View File
@@ -95,6 +95,22 @@ CREATE TABLE IF NOT EXISTS comment (
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). 같은 표 재요청 시 취소, 반대면 전환. -- 게시글 추천/비추천 (회원당 1표, UP/DOWN). 같은 표 재요청 시 취소, 반대면 전환.
CREATE TABLE IF NOT EXISTS post_vote ( CREATE TABLE IF NOT EXISTS post_vote (
post_id BIGINT NOT NULL, post_id BIGINT NOT NULL,
+4
View File
@@ -41,6 +41,10 @@ ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COM
-- 멤버십 만료일 (유료 구독). NULL = 만료 없음(관리자 영구 부여) 또는 무료. 만료 지나면 스케줄러가 FREE 로 강등. -- 멤버십 만료일 (유료 구독). NULL = 만료 없음(관리자 영구 부여) 또는 무료. 만료 지나면 스케줄러가 FREE 로 강등.
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_expires_at DATETIME NULL COMMENT '유료 멤버십 만료일시' AFTER plan; 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 ( CREATE TABLE IF NOT EXISTS iap_purchase (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
@@ -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>
+24 -5
View File
@@ -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">
+20 -2
View File
@@ -11,11 +11,12 @@
<result property="authorGooglePicture" column="author_google_picture"/> <result property="authorGooglePicture" column="author_google_picture"/>
<result property="authorProfileImage" column="author_profile_image"/> <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 c.id, c.post_id, c.author_id, c.author_name, c.content, c.created_at, SELECT c.id, c.post_id, c.author_id, c.author_name, c.content, c.blocked, c.created_at,
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image m.google_picture AS author_google_picture, m.profile_image AS author_profile_image
FROM comment c FROM comment c
LEFT JOIN member m ON m.id = c.author_id LEFT JOIN member m ON m.id = c.author_id
@@ -24,13 +25,30 @@
</select> </select>
<select id="findById" parameterType="long" resultMap="commentResultMap"> <select id="findById" parameterType="long" resultMap="commentResultMap">
SELECT c.id, c.post_id, c.author_id, c.author_name, c.content, c.created_at, SELECT c.id, c.post_id, c.author_id, c.author_name, c.content, c.blocked, c.created_at,
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image m.google_picture AS author_google_picture, m.profile_image AS author_profile_image
FROM comment c FROM comment c
LEFT JOIN member m ON m.id = c.author_id LEFT JOIN member m ON m.id = c.author_id
WHERE c.id = #{id} WHERE c.id = #{id}
</select> </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>
<insert id="insert" parameterType="com.sb.web.board.domain.Comment" <insert id="insert" parameterType="com.sb.web.board.domain.Comment"
useGeneratedKeys="true" keyProperty="id"> useGeneratedKeys="true" keyProperty="id">
INSERT INTO comment (post_id, author_id, author_name, content, created_at) INSERT INTO comment (post_id, author_id, author_name, content, created_at)
@@ -26,4 +26,12 @@
FROM iap_purchase WHERE purchase_token = #{purchaseToken} FROM iap_purchase WHERE purchase_token = #{purchaseToken}
</select> </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> </mapper>
+19 -8
View File
@@ -17,6 +17,8 @@
<result property="role" column="role"/> <result property="role" column="role"/>
<result property="plan" column="plan"/> <result property="plan" column="plan"/>
<result property="planExpiresAt" column="plan_expires_at"/> <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="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"/>
@@ -24,7 +26,7 @@
</resultMap> </resultMap>
<sql id="columns"> <sql id="columns">
id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, plan_expires_at, points, 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">
@@ -97,20 +99,29 @@
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 id="updatePlan">
UPDATE member SET plan = #{plan}, plan_expires_at = NULL, updated_at = NOW() WHERE id = #{id} UPDATE member SET plan = #{plan}, plan_expires_at = NULL, plan_product = NULL, plan_auto_renew = 0,
updated_at = NOW() WHERE id = #{id}
</update> </update>
<!-- 결제로 멤버십 부여/갱신: plan + 만료일 --> <!-- 결제로 멤버십 부여/갱신: plan + 만료일 + 구독상품 + 자동갱신 -->
<update id="updateMembership"> <update id="updateMembership">
UPDATE member SET plan = #{plan}, plan_expires_at = #{expiresAt}, updated_at = NOW() WHERE id = #{id} UPDATE member SET plan = #{plan}, plan_expires_at = #{expiresAt},
plan_product = #{product}, plan_auto_renew = #{autoRenew}, updated_at = NOW()
WHERE id = #{id}
</update> </update>
<!-- 만료된 유료회원 자동 강등 (스케줄러) --> <!-- 구독 해지/재개: 자동 갱신 플래그만 변경 (이용은 만료일까지 유지) -->
<update id="updateAutoRenew">
UPDATE member SET plan_auto_renew = #{autoRenew}, updated_at = NOW() WHERE id = #{id}
</update>
<!-- 만료된 '해지(자동갱신 off)' 유료회원만 강등 (자동갱신 건은 마켓 갱신/RTDN 이 관리) -->
<update id="downgradeExpired"> <update id="downgradeExpired">
UPDATE member SET plan = 'FREE', updated_at = NOW() UPDATE member SET plan = 'FREE', plan_product = NULL, plan_auto_renew = 0, updated_at = NOW()
WHERE plan = 'PREMIUM' AND plan_expires_at IS NOT NULL AND plan_expires_at &lt; NOW() WHERE plan = 'PREMIUM' AND plan_auto_renew = 0
AND plan_expires_at IS NOT NULL AND plan_expires_at &lt; NOW()
</update> </update>
<update id="updateStatus"> <update id="updateStatus">
+19
View File
@@ -71,6 +71,25 @@
<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 p.id, p.title, p.content, p.author_id, p.author_name, p.view_count, SELECT p.id, p.title, p.content, p.author_id, p.author_name, p.view_count,
p.blocked, p.block_reason, p.notice, p.category, p.created_at, p.updated_at, p.blocked, p.block_reason, p.notice, p.category, p.created_at, p.updated_at,
@@ -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>
+18 -4
View File
@@ -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>
@@ -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() {