Compare commits

..

33 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
ByungCheol d5366e8e72 feat: 태그 그룹 게시판 매핑 엄격 모드 — 미체크 그룹은 어디에도 미노출
CI / build (push) Failing after 12m19s
- listWritableGroups: 명시적으로 매핑된 게시판에만 노출(boards.contains)
- 게시판 미지정 그룹은 글쓰기에서 숨김 (수정 화면은 기존 글 태그 유실 방지로 전체 표시 유지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:24:12 +09:00
ByungCheol 1e93280140 feat: 인앱 결제(Google Play) 스캐폴드 + 멤버십 만료 처리
CI / build (push) Failing after 13m46s
- member.plan_expires_at + iap_purchase 테이블(중복 결제 방지)
- POST /api/billing/google/verify: 구매 검증 후 PREMIUM 부여/갱신(만료 연장), 세션 plan 즉시 동기화
- GET /api/billing/products: 구독 상품(월간/연간)
- 테스트 모드(billing.test-mode=true): test- 토큰 검증 없이 승인 → 전 과정 테스트 가능
- GooglePlayVerifier 인터페이스 + 스텁(실연동 지점), 실연동 시 구현체 교체
- 관리자 수동 plan 변경은 만료일 없이(영구), 결제는 만료일 부여
- 만료 자동 강등 스케줄러(매시), MemberResponse.planExpiresAt 노출

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:12:45 +09:00
ByungCheol d1c13e7cc1 feat: 포인트 적립내역 조회 — GET /auth/point-history
CI / build (push) Failing after 12m7s
- point_history 최신순 조회(최대 100건), PointHistoryResponse
- PointService.getHistory, 인터셉터 경로 등록

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:44:24 +09:00
ByungCheol 629ab1f811 feat: 기본 분류 초기 시드 — default_category 비어있으면 기본 세트 생성
- 기동 시 1회(ApplicationRunner), 데이터 없을 때만 생성(운영 데이터 미간섭)
- 지출 10대분류(+소분류)·수입 3대분류(+소분류) 기본 템플릿
- 관리자가 이후 수정/정렬/삭제 가능

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:44:23 +09:00
ByungCheol b94f162f7e refactor: 게시판별 태그 매핑 도입으로 불필요해진 board_setting 제거
CI / build (push) Failing after 11m32s
- BoardSettingMapper(+XML)/BoardSettingRequest/Response 삭제
- TagService board_setting 의존 제거, /admin/board-setting 엔드포인트 제거
- board_setting 테이블 DDL 제거
- 글 수정 시 전체 그룹 조회(기존 글 태그 유실 방지): listWritableGroups(blank)=전체

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:04:59 +09:00
ByungCheol caaad65ca2 feat: 태그 그룹을 게시판별로 매핑 — 게시판 성격에 맞는 태그만 노출
CI / build (push) Failing after 12m11s
- tag_category_board 매핑 테이블 추가(그룹 ↔ community/saving/tips)
- TagCategory 응답에 boards 추가, 생성/수정 시 매핑 저장(유효 게시판만)
- listWritableGroups(category): 해당 게시판에 매핑된 그룹만(매핑 없으면 전체=하위호환)
- GET /board/tag-groups?category=... 로 게시판별 필터

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:34:19 +09:00
ByungCheol b4344f5270 feat: 게시판 인기글/블라인드/자기글 추천차단 + 프로필 전역 동기화
CI / build (push) Failing after 15m11s
- GET /api/auth/profile: 아바타·포인트 포함 전체 프로필(재로그인 없이 최신화)
- 자기 글/댓글 추천·비추천 차단(400)
- 인기 글: 등록 1일 이내 + 추천 50건 이상이면 목록 상단 최대 3건 노출(hot)
- 인기 댓글: 동일 조건으로 댓글 목록 상단 정렬(hot)
- PostSummary 에 downCount 추가(비추천 20+ 블라인드 판정용)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:21:19 +09:00
ByungCheol ef9dace1df feat: 게시판 추천/비추천 + 작성자 아바타 + 추천자 + 활동 포인트
CI / build (push) Failing after 12m4s
- 작성자 아바타: post/comment 조회 시 member JOIN(google_picture/profile_image),
  목록·상세·댓글 응답에 노출
- 추천/비추천: post_vote/comment_vote 테이블 + 토글 API
  (POST /board/posts|comments/{id}/vote), 게시글/댓글 응답에 up/down/myVote
- 추천자 목록: GET /board/posts/{id}/recommenders + PostDetail.recommenders
- 포인트: member.points + point_history. 글/댓글 작성 시 10P, 하루 3회 한도(합산).
  PointService, GET /auth/points, MemberResponse.points 노출
- @MapperScan 에 point.mapper 추가, AuthController WebMvc 테스트 MockBean 보강

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:27:35 +09:00
ByungCheol 98f5f51112 feat: 프로필 사진 — 구글 아바타 동기화 + 사용자 지정 이미지
- member.google_picture(구글 아바타 URL), profile_image(사용자 지정 data URL) 컬럼 추가(멱등)
- 구글 로그인 시 picture 클레임 추출 → 신규 저장, 기존은 변경 시 동기화
- PUT /api/auth/profile-image: 사용자 지정 사진 설정/해제(빈값=해제→구글 폴백), data URL·용량 검증
- MemberResponse 에 googlePicture/profileImage 노출(표시 우선순위 프론트 처리)
- 관리자 목록(findAll)은 무거운 profile_image 제외해 가볍게 조회

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:39:35 +09:00
ByungCheol 6256d3e63d feat: 멤버십(무료/유료) 게이팅 — 백엔드
CI / build (push) Failing after 13m40s
- member.plan(FREE/PREMIUM) 컬럼 추가 + SessionUser/MemberResponse 노출
- auth_session 백업에 plan 저장(세션 복원 시 유료 상태 유지)
- 관리자 회원 plan 변경 API (PUT /api/admin/members/{id}/plan)
- PremiumInterceptor 로 유료 전용 경로 차단(통계/예산/고정지출/태그/OCR/투자/카드알림/백업)
  · 관리자는 플랜과 무관하게 허용, 무료 회원은 403 PREMIUM_REQUIRED
- WebConfigTest: 유료 경로 등록 회귀 가드 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:02:19 +09:00
ByungCheol 93e06c9475 ci: main 푸시 시 CI 중복 실행 제거(Deploy만) — 배포 시간 단축
CI / build (push) Failing after 13m44s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:22:44 +09:00
ByungCheol f130d53f62 fix: 복구 - 계좌를 이름 대신 원본 ID 기준 매핑 + 잘못된 이체 스킵
CI / build (push) Failing after 12m9s
- 같은 이름 계좌가 있어도 정확히 매핑(이름 충돌로 '출금=입금' 오류 해결)
- 이체인데 계좌 없거나 같으면 해당 항목만 건너뜀(전체 롤백 방지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:03:11 +09:00
ByungCheol 3b46d2ba09 feat: 데이터 가져오기(복구) — 엑셀 백업으로 전체 교체 (트랜잭션)
CI / build (push) Failing after 12m39s
- POST /api/account/restore: 기존 데이터 전부 삭제 후 이름 기준 재생성
  (계좌→태그→분류(대/소)→내역→고정→예산→자주내역), 한 트랜잭션(실패 시 롤백)
- 빈 데이터 안전장치, 투자 종목/매매는 복구 대상 외(삭제됨)
- BackupMapper(deleteByMember), RestoreRequest DTO, BackupService/Controller

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 08:38:21 +09:00
ByungCheol bf6fb97ed8 feat: 게시글 수정 시에도 공지 체크박스 반영 (관리자)
CI / build (push) Failing after 11m18s
- BoardService.update: 관리자면 req.notice 로 공지 갱신
  (작성/수정 체크박스로 공지 on/off, 상세 토글 버튼 제거 대응)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:59:04 +09:00
ByungCheol 8d8d26ec3a feat: 게시판 공지 기능 (관리자) — 목록 최상단 고정
CI / build (push) Failing after 14m19s
- post.notice 컬럼 추가(멱등 ALTER)
- 목록 정렬 ORDER BY notice DESC, id DESC (공지 최상단)
- 작성 시 공지 설정(관리자만 반영) + /posts/{id}/notice·unnotice 토글
- Post/PostSummary/PostDetail/PostRequest 에 notice 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:06:48 +09:00
ByungCheol b48d8c667c feat: 구글 로그인 — 같은 이메일이면 기존 계정에 자동 연결
CI / build (push) Failing after 14m59s
- member.google_id 컬럼 추가(멱등 ALTER) + 기존 GOOGLE 계정 sub 백필
- googleLogin: google_id 조회 → 없으면 검증된 동일 이메일 계정(LOCAL 우선)에
  연결 → 그래도 없으면 신규 생성. provider 는 유지(아이디 로그인/프로필 편집 보존)
- MemberMapper: findByGoogleId/findByEmailForLink/linkGoogle 추가, google_id 매핑

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:01:57 +09:00
ByungCheol 09efd65fa9 feat: 구글 로그인/가입 — ID 토큰 검증 후 세션 발급
CI / build (push) Failing after 14m2s
- AuthService.googleLogin: 구글 tokeninfo 로 ID 토큰 검증(aud==클라이언트 ID)
  후 provider=GOOGLE 회원 조회/자동가입 → 세션 발급(login 과 동일 경로)
- GoogleLoginRequest DTO, /api/auth/google·/google-client-id 엔드포인트
- application.yml: app.google-client-id=${GOOGLE_CLIENT_ID:}

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 18:02:19 +09:00
ByungCheol a1697a7a22 feat: 자주 쓰는 내역(빠른등록) + 문자/푸시 텍스트 파싱 API
CI / build (push) Failing after 15m21s
- quick_entry 테이블 + 도메인/매퍼/서비스/컨트롤러 (/account/quick-entries CRUD)
- /account/entries/parse: 문자·푸시 텍스트를 파서로 분석해 결과만 반환(저장X)
  → iPhone 등 알림 자동수집 불가 시 복사→붙여넣기 자동채움용

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:47:00 +09:00
ByungCheol 63b3ffea24 feat: 기본(디폴트) 분류 — 관리자 설정 + 사용자 불러오기
CI / build (push) Failing after 13m58s
- default_category 테이블(전체 공용) + 도메인/매퍼/서비스
- 관리자 API /api/admin/default-categories (CRUD·reorder, ADMIN 보호)
- 사용자 /account/categories/import: 기존 내역 기반 → 기본분류 복사로 변경
  (대/소분류 계층 유지, 같은 이름은 건너뜀; CategoryMapper.findByMemberTypeName 추가)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:03:38 +09:00
ByungCheol 5f21cc4474 feat: 분류 대/소분류(2단계) — account_category.parent_id
CI / build (push) Failing after 11m45s
- parent_id 추가(CREATE + ALTER IF NOT EXISTS 멱등 마이그레이션)
- 도메인/DTO/매퍼에 parentId 노출, 2단계 검증(대분류 아래만 소분류)
- 자식 있는 대분류는 소분류화·삭제 차단(countChildren)
- 통계/예산/내역은 소분류 이름 기준 그대로(무변경)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:16:03 +09:00
ByungCheol 3fc233d697 feat: 순자산 추이 주간 집계 추가 (/networth/trend?unit=WEEK)
CI / build (push) Failing after 14m43s
- weeklyWalletFlow(월요일 기준 주별 순현금흐름) + netWorthTrendWeekly(기본 13주)
- 컨트롤러에 unit=WEEK&weeks=N 지원(기존 월별은 그대로)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:38:06 +09:00
ByungCheol 7de643022b feat: 고정지출 생성분을 '확인 필요(pending)'로 — 실제 처리 여부 확인
CI / build (push) Failing after 15m1s
- RecurringService.buildEntry: pending=true로 생성 → 가계부 '확인 필요' 목록에 노출,
  사용자가 실제 결제·이체 확인 후 확정(빈 body confirm은 분류/계좌 유지)
- 메모 없으면 제목으로 채워 확인 목록에서 식별 쉽게

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:13:14 +09:00
ByungCheol 6eef6cbd29 feat: 계좌 관리에 현금(CASH) 유형 추가
CI / build (push) Failing after 14m23s
- WalletRequest 유형 검증에 CASH 허용
- netWorth: CASH를 자산으로 합산(기존 else 분기에서 부채로 잡히던 것 방지)
- 추이(trend)는 부호있는 openingBalance+흐름이라 CASH 자동 반영(변경 불필요)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:43:29 +09:00
122 changed files with 3769 additions and 201 deletions
+2 -1
View File
@@ -1,8 +1,9 @@
name: CI
on:
# main 은 Deploy(clean build=테스트 포함)가 게이트 역할을 하므로 CI 중복 실행 제외(배포 시간 단축).
push:
branches: [main, dev]
branches: [dev]
pull_request:
branches: [main, dev]
+4
View File
@@ -2,6 +2,10 @@ HELP.md
.gradle
build/
# 로컬 전용 테스트/시드 스크립트 (실제 이메일·프리미엄 부여 포함 — 커밋 금지)
scripts/seed-test-*.sql
scripts/cleanup-test-*.sql
# 환경변수 / 비밀 정보 (실제 .env 는 커밋 금지, .env.example 만 추적)
.env
!gradle/wrapper/gradle-wrapper.jar
@@ -7,7 +7,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@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 static void main(String[] args) {
@@ -3,6 +3,7 @@ package com.sb.web.account.controller;
import com.sb.web.account.dto.AccountEntryRequest;
import com.sb.web.account.dto.AccountEntryResponse;
import com.sb.web.account.dto.AccountSummary;
import com.sb.web.account.dto.ParsedEntryResponse;
import com.sb.web.account.dto.AccountTagRequest;
import com.sb.web.account.dto.AccountTagResponse;
import com.sb.web.account.dto.CategoryStat;
@@ -167,11 +168,16 @@ public class AccountController {
return accountService.categoryStats(current.getId(), type, year, month);
}
/** 월별 순자산 추이 */
/** 순자산 추이 (unit=WEEK 면 주별, weeks 개수 / 그 외 월별, months 개수) */
@GetMapping("/networth/trend")
public List<NetWorthPoint> netWorthTrend(
@RequestParam(required = false) Integer months,
@RequestParam(required = false) Integer weeks,
@RequestParam(defaultValue = "MONTH") String unit,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
if ("WEEK".equalsIgnoreCase(unit)) {
return accountService.netWorthTrendWeekly(current.getId(), weeks);
}
return accountService.netWorthTrend(current.getId(), months);
}
@@ -236,6 +242,14 @@ public class AccountController {
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
/** 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움. */
@PostMapping("/entries/parse")
public ParsedEntryResponse parseText(
@RequestBody NotificationRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return accountService.parseText(req);
}
/** 미확인 내역 확정 (분류/계좌 보정) */
@PostMapping("/entries/{id}/confirm")
public AccountEntryResponse confirm(
@@ -0,0 +1,33 @@
package com.sb.web.account.controller;
import com.sb.web.account.dto.RestoreRequest;
import com.sb.web.account.service.BackupService;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RestController;
/**
* 데이터 가져오기(복구) API. (로그인 필요 — /api/account/** 보호)
* POST /api/account/restore 엑셀 백업으로 전체 데이터 복구(기존 데이터 덮어씀)
*/
@RestController
@RequestMapping("/api/account")
@RequiredArgsConstructor
public class BackupController {
private final BackupService backupService;
@PostMapping("/restore")
public ResponseEntity<Void> restore(
@RequestBody RestoreRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
backupService.restore(current.getId(), req);
return ResponseEntity.noContent().build();
}
}
@@ -50,8 +50,22 @@ public class BudgetController {
@GetMapping
public List<BudgetResponse> list(
@RequestParam int year,
@RequestParam int month,
@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")
@@ -74,9 +88,11 @@ public class BudgetController {
@PostMapping
public ResponseEntity<BudgetResponse> create(
@RequestParam int year,
@RequestParam int month,
@Valid @RequestBody BudgetRequest req,
@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}")
@@ -61,10 +61,10 @@ public class CategoryController {
return categoryService.reorder(current.getId(), req.getType(), req.getIds());
}
/** 기존 내역의 분류를 목록으로 가져오기 */
/** 관리자가 정의한 기본 분류를 내 분류로 불러오기 */
@PostMapping("/import")
public List<CategoryResponse> importFromEntries(
public List<CategoryResponse> importDefaults(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return categoryService.importFromEntries(current.getId());
return categoryService.importDefaults(current.getId());
}
}
@@ -0,0 +1,37 @@
package com.sb.web.account.controller;
import com.sb.web.account.service.FxService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
/**
* 환율 조회 API. 외화 결제 입력 시 통화→원화 환율 자동 채움용.
* GET /api/fx/rate?from=USD&to=KRW → { from, to, rate }
*/
@RestController
@RequestMapping("/api/fx")
public class FxController {
private final FxService fxService;
public FxController(FxService fxService) {
this.fxService = fxService;
}
@GetMapping("/rate")
public Map<String, Object> rate(@RequestParam String from,
@RequestParam(defaultValue = "KRW") String to) {
BigDecimal rate = fxService.getRate(from, to);
Map<String, Object> res = new HashMap<>();
res.put("from", from == null ? null : from.toUpperCase());
res.put("to", to == null ? null : to.toUpperCase());
res.put("rate", rate); // 조회 실패 시 null
return res;
}
}
@@ -0,0 +1,46 @@
package com.sb.web.account.controller;
import com.sb.web.account.dto.QuickEntryRequest;
import com.sb.web.account.dto.QuickEntryResponse;
import com.sb.web.account.service.QuickEntryService;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 자주 쓰는 내역(빠른 등록 템플릿) API. (/api/account/quick-entries — 로그인 필요, 본인 데이터만)
*/
@RestController
@RequestMapping("/api/account/quick-entries")
@RequiredArgsConstructor
public class QuickEntryController {
private final QuickEntryService service;
@GetMapping
public List<QuickEntryResponse> list(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return service.list(current.getId());
}
@PostMapping
public ResponseEntity<QuickEntryResponse> create(
@Valid @RequestBody QuickEntryRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req, current.getId()));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(
@PathVariable Long id,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
service.delete(id, current.getId());
return ResponseEntity.noContent().build();
}
}
@@ -21,6 +21,7 @@ public class AccountCategory implements Serializable {
private Long memberId;
private String type; // INCOME / EXPENSE
private String name;
private Long parentId; // 대분류 id (NULL=대분류, 값=소분류)
private Integer sortOrder;
private LocalDateTime createdAt;
}
@@ -6,6 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@@ -30,6 +31,9 @@ public class AccountEntry implements Serializable {
private Long toWalletId; // 이체 입금 계좌
private String toWalletName;
private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null)
private String currency; // 외화 통화코드(USD 등). null/KRW = 원화
private BigDecimal originalAmount; // 외화 원본 금액
private BigDecimal exchangeRate; // 적용 환율(외화 1단위→원화)
private Boolean pending; // 확인 필요(미확정) 여부 — 알림 자동인식 건
private String notifKey; // 알림 자동인식 중복방지 키
private LocalDateTime createdAt;
@@ -28,6 +28,8 @@ public class Budget implements Serializable {
private Long weekly;
private Long monthly;
private Long yearly;
private Integer year; // 예산 연도(월별 예산)
private Integer month; // 예산 월(1~12)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,26 @@
package com.sb.web.account.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 기본(디폴트) 분류 — 전체 사용자 공용 템플릿(관리자 관리). 사용자는 자기 분류로 복사해 사용.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DefaultCategory implements Serializable {
private Long id;
private String type; // INCOME / EXPENSE
private String name;
private Long parentId; // 대분류 id (NULL=대분류, 값=소분류)
private Integer sortOrder;
private LocalDateTime createdAt;
}
@@ -0,0 +1,31 @@
package com.sb.web.account.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 자주 쓰는 내역(빠른 등록 템플릿) — 사용자별.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QuickEntry implements Serializable {
private Long id;
private Long memberId;
private String label;
private String type; // INCOME / EXPENSE
private String category;
private Long amount;
private String memo;
private Long walletId;
private Integer sortOrder;
private LocalDateTime createdAt;
private String walletName; // 조회 시 조인(표시용)
}
@@ -6,6 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@@ -29,6 +30,11 @@ public class Wallet implements Serializable {
private Long openingBalance; // 초기 잔액(부호있음: 자산+, 부채-)
private LocalDate openingDate;
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 LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@@ -48,4 +49,10 @@ public class AccountEntryRequest {
/** 선택한 태그 ID 목록 (태그 관리에 등록된 태그, 선택) */
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.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@@ -26,6 +27,9 @@ public class AccountEntryResponse {
private String toWalletName;
private Integer installmentMonths;
private Boolean pending;
private String currency;
private BigDecimal originalAmount;
private BigDecimal exchangeRate;
private List<String> tags;
public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
@@ -42,6 +46,9 @@ public class AccountEntryResponse {
.toWalletName(e.getToWalletName())
.installmentMonths(e.getInstallmentMonths())
.pending(Boolean.TRUE.equals(e.getPending()))
.currency(e.getCurrency())
.originalAmount(e.getOriginalAmount())
.exchangeRate(e.getExchangeRate())
.tags(tags)
.build();
}
@@ -18,4 +18,7 @@ public class CategoryRequest {
@NotBlank(message = "분류 이름을 입력하세요.")
@Size(max = 50, message = "분류 이름은 50자 이내여야 합니다.")
private String name;
/** 대분류 id. null=대분류로 생성, 값=해당 대분류의 소분류로 생성/이동 */
private Long parentId;
}
@@ -14,8 +14,11 @@ public class CategoryResponse {
private Long id;
private String type;
private String name;
private Long parentId; // 대분류 id (null=대분류)
public static CategoryResponse from(AccountCategory c) {
return CategoryResponse.builder().id(c.getId()).type(c.getType()).name(c.getName()).build();
return CategoryResponse.builder()
.id(c.getId()).type(c.getType()).name(c.getName()).parentId(c.getParentId())
.build();
}
}
@@ -0,0 +1,20 @@
package com.sb.web.account.dto;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDate;
/**
* 문자/푸시 텍스트 파싱 결과 (내역 추가 폼 자동 채움용). 저장하지 않음.
*/
@Data
@Builder
public class ParsedEntryResponse {
private boolean recognized; // 거래로 인식되었는가
private String type; // INCOME / EXPENSE
private long amount;
private String merchant; // 가맹점/적요(메모 후보)
private LocalDate date;
}
@@ -0,0 +1,32 @@
package com.sb.web.account.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 자주 쓰는 내역 생성 요청.
*/
@Data
public class QuickEntryRequest {
@Size(max = 50, message = "이름은 50자 이내여야 합니다.")
private String label;
@Pattern(regexp = "INCOME|EXPENSE", message = "구분이 올바르지 않습니다.")
private String type;
@Size(max = 50)
private String category;
@NotNull(message = "금액을 입력하세요.")
@Positive(message = "금액은 0보다 커야 합니다.")
private Long amount;
@Size(max = 255)
private String memo;
private Long walletId;
}
@@ -0,0 +1,30 @@
package com.sb.web.account.dto;
import com.sb.web.account.domain.QuickEntry;
import lombok.Builder;
import lombok.Data;
/**
* 자주 쓰는 내역 응답.
*/
@Data
@Builder
public class QuickEntryResponse {
private Long id;
private String label;
private String type;
private String category;
private Long amount;
private String memo;
private Long walletId;
private String walletName;
public static QuickEntryResponse from(QuickEntry q) {
return QuickEntryResponse.builder()
.id(q.getId()).label(q.getLabel()).type(q.getType()).category(q.getCategory())
.amount(q.getAmount()).memo(q.getMemo()).walletId(q.getWalletId())
.walletName(q.getWalletName())
.build();
}
}
@@ -0,0 +1,83 @@
package com.sb.web.account.dto;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
/**
* 데이터 복구(가져오기) 요청 — 엑셀 백업을 파싱한 결과.
* 계좌는 원본 ID(oldId) 기준으로 매핑(같은 이름 계좌가 있어도 정확). 분류·태그는 이름 기준(유니크).
*/
@Data
public class RestoreRequest {
private List<Wal> wallets;
private List<Cat> categories;
private List<String> tags;
private List<Entry> entries;
private List<Recur> recurrings;
private List<BudgetRequest> budgets;
private List<Quick> quickEntries;
@Data
public static class Wal {
private Long oldId; // 원본 계좌 id (매핑 키)
private String type;
private String name;
private String issuer;
private String accountNumber;
private String cardType;
private Long openingBalance;
private LocalDate openingDate;
private Long currentValue;
}
@Data
public static class Cat {
private String type;
private String name;
private String parent; // 대분류명 (없으면 대분류)
}
@Data
public static class Entry {
private LocalDate entryDate;
private String type;
private String category;
private Long amount;
private String memo;
private Long walletId; // 원본 계좌 id
private Long toWalletId; // 원본 입금 계좌 id(이체)
private Integer installmentMonths;
private List<String> tags; // 태그명
}
@Data
public static class Recur {
private String title;
private String type;
private Long amount;
private String category;
private String memo;
private Long walletId;
private Long toWalletId;
private String frequency;
private Integer dayOfMonth;
private Integer dayOfWeek;
private Integer monthOfYear;
private LocalDate startDate;
private LocalDate endDate;
private Boolean active;
}
@Data
public static class Quick {
private String label;
private String type;
private String category;
private Long amount;
private String memo;
private Long walletId;
}
}
@@ -1,10 +1,13 @@
package com.sb.web.account.dto;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
@@ -14,7 +17,7 @@ import java.time.LocalDate;
public class WalletRequest {
@NotBlank(message = "유형을 선택하세요.")
@Pattern(regexp = "BANK|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.")
@Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.")
private String type;
@NotBlank(message = "이름을 입력하세요.")
@@ -37,4 +40,17 @@ public class WalletRequest {
/** 현재 평가금액 (INVEST 전용, 수동 갱신) */
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.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
@@ -31,6 +32,13 @@ public class WalletResponse {
private Long currentValue; // 평가액 직접입력값(퇴직연금·연금 등 수동 갱신). 있으면 총평가로 사용
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) {
return WalletResponse.builder()
.id(w.getId())
@@ -43,6 +51,11 @@ public class WalletResponse {
.openingDate(w.getOpeningDate())
.balance(balance)
.currentValue(w.getCurrentValue())
.loanAmount(w.getLoanAmount())
.loanRate(w.getLoanRate())
.loanMethod(w.getLoanMethod())
.loanMonths(w.getLoanMonths())
.loanStart(w.getLoanStart())
.build();
}
}
@@ -47,6 +47,9 @@ public interface AccountEntryMapper {
/** 월별 순현금흐름(수입−지출, 계좌 지정분) — {ym, net} 행 반환 (순자산 추이용) */
List<Map<String, Object>> monthlyWalletFlow(@Param("memberId") Long memberId);
/** 주별 순현금흐름(수입−지출, 계좌 지정분) — {wk(=주 월요일 yyyy-MM-dd), net} (주간 순자산 추이용) */
List<Map<String, Object>> weeklyWalletFlow(@Param("memberId") Long memberId);
/** 기간 버킷(일/주/월/년)별 수입·지출 — {bucket, income, expense} (이체 제외) */
List<Map<String, Object>> statsByPeriod(@Param("memberId") Long memberId,
@Param("unit") String unit,
@@ -0,0 +1,23 @@
package com.sb.web.account.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 데이터 복구 시 회원의 기존 가계부 데이터를 전부 삭제(트랜잭션 내에서 호출).
*/
@Mapper
public interface BackupMapper {
int deleteEntryTags(@Param("memberId") Long memberId);
int deleteEntries(@Param("memberId") Long memberId);
int deleteRecurrings(@Param("memberId") Long memberId);
int deleteBudgets(@Param("memberId") Long memberId);
int deleteBudgetIncome(@Param("memberId") Long memberId);
int deleteQuickEntries(@Param("memberId") Long memberId);
int deleteInvestTrades(@Param("memberId") Long memberId);
int deleteInvestHoldings(@Param("memberId") Long memberId);
int deleteCategories(@Param("memberId") Long memberId);
int deleteTags(@Param("memberId") Long memberId);
int deleteWallets(@Param("memberId") Long memberId);
}
@@ -12,13 +12,24 @@ import java.util.List;
@Mapper
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);
int countByCategory(@Param("memberId") Long memberId,
@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);
@@ -16,6 +16,14 @@ public interface CategoryMapper {
AccountCategory findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
/** 이름으로 조회 (기본분류 불러오기 중복 확인용) */
AccountCategory findByMemberTypeName(@Param("memberId") Long memberId,
@Param("type") String type,
@Param("name") String name);
/** 해당 대분류(parent_id)에 속한 소분류 개수 */
int countChildren(@Param("memberId") Long memberId, @Param("parentId") Long parentId);
int countByName(@Param("memberId") Long memberId,
@Param("type") String type,
@Param("name") String name,
@@ -0,0 +1,32 @@
package com.sb.web.account.mapper;
import com.sb.web.account.domain.DefaultCategory;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 기본(디폴트) 분류 매퍼 — 전체 공용(소유자 격리 없음).
*/
@Mapper
public interface DefaultCategoryMapper {
List<DefaultCategory> findAll();
DefaultCategory findById(@Param("id") Long id);
int countChildren(@Param("parentId") Long parentId);
int countByName(@Param("type") String type, @Param("name") String name, @Param("excludeId") Long excludeId);
int insert(DefaultCategory category);
int update(DefaultCategory category);
int delete(@Param("id") Long id);
int updateSortOrder(@Param("id") Long id, @Param("sortOrder") int sortOrder);
Integer maxSortOrder(@Param("type") String type);
}
@@ -0,0 +1,24 @@
package com.sb.web.account.mapper;
import com.sb.web.account.domain.QuickEntry;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 자주 쓰는 내역 매퍼 — 소유자(member_id) 격리.
*/
@Mapper
public interface QuickEntryMapper {
List<QuickEntry> findByMember(@Param("memberId") Long memberId);
QuickEntry findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
int insert(QuickEntry e);
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
Integer maxSortOrder(@Param("memberId") Long memberId);
}
@@ -8,6 +8,7 @@ import com.sb.web.account.dto.AccountEntryResponse;
import com.sb.web.account.dto.AccountSummary;
import com.sb.web.account.dto.ConfirmRequest;
import com.sb.web.account.dto.NotificationRequest;
import com.sb.web.account.dto.ParsedEntryResponse;
import com.sb.web.account.dto.AccountTagRequest;
import com.sb.web.account.dto.AccountTagResponse;
import com.sb.web.account.dto.CategoryStat;
@@ -26,6 +27,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.HashMap;
@@ -128,6 +130,66 @@ public class AccountService {
return result;
}
/** 주별 순자산 추이 (월요일 기준 버킷). 기본 13주(최근 약 3개월). */
public List<NetWorthPoint> netWorthTrendWeekly(Long memberId, Integer weeks) {
int n = (weeks == null || weeks <= 0 || weeks > 53) ? 13 : weeks;
LocalDate today = LocalDate.now();
LocalDate currentMon = today.minusDays(today.getDayOfWeek().getValue() - 1L); // 이번 주 월요일
LocalDate startMon = currentMon.minusWeeks(n - 1L);
Map<String, Long> flow = new HashMap<>();
for (Map<String, Object> row : mapper.weeklyWalletFlow(memberId)) {
Object net = row.get("net");
flow.put((String) row.get("wk"), net == null ? 0L : ((Number) net).longValue());
}
// 개시잔액: 개시주가 시작주 이전(또는 미상)이면 기준선, 범위 내면 해당 주 버킷
List<Wallet> ws = walletMapper.findByMember(memberId);
Map<String, Long> openingByWeek = new HashMap<>();
long baseline = 0;
for (Wallet w : ws) {
long ob = w.getOpeningBalance() == null ? 0L : w.getOpeningBalance();
if (ob == 0) {
continue;
}
LocalDate od = w.getOpeningDate();
LocalDate om = od == null ? null : od.minusDays(od.getDayOfWeek().getValue() - 1L);
if (om == null || om.isBefore(startMon)) {
baseline += ob;
} else if (!om.isAfter(currentMon)) {
openingByWeek.merge(om.toString(), ob, Long::sum);
}
}
String startKey = startMon.toString();
for (Map.Entry<String, Long> e : flow.entrySet()) {
if (e.getKey().compareTo(startKey) < 0) {
baseline += e.getValue();
}
}
List<NetWorthPoint> result = new ArrayList<>();
long running = baseline;
for (LocalDate m = startMon; !m.isAfter(currentMon); m = m.plusWeeks(1)) {
String key = m.toString();
running += flow.getOrDefault(key, 0L);
running += openingByWeek.getOrDefault(key, 0L);
result.add(NetWorthPoint.builder().month(key).netWorth(running).build());
}
// 투자 손익(실현+평가)은 과거 주별 이력이 없으므로 마지막 점에만 가산 (월간과 동일)
if (!result.isEmpty()) {
long investAdjust = 0;
for (InvestService.WalletInvest wi : investService.valuationByWallet(memberId).values()) {
investAdjust += wi.cashDelta + wi.stockEval;
}
if (investAdjust != 0) {
NetWorthPoint last = result.get(result.size() - 1);
last.setNetWorth(last.getNetWorth() + investAdjust);
}
}
return result;
}
/** 기간 버킷(일/주/월/년)별 수입·지출 통계 */
public List<PeriodStat> stats(Long memberId, String unit, Integer year, Integer month) {
String u = switch (unit == null ? "" : unit.toUpperCase()) {
@@ -172,6 +234,9 @@ public class AccountService {
.walletId(req.getWalletId())
.toWalletId(transfer ? req.getToWalletId() : null)
.installmentMonths(installmentOf(req))
.currency(req.getCurrency())
.originalAmount(req.getOriginalAmount())
.exchangeRate(req.getExchangeRate())
.build();
mapper.insert(entry);
applyTags(entry.getId(), req.getTagIds(), memberId);
@@ -191,6 +256,9 @@ public class AccountService {
entry.setWalletId(req.getWalletId());
entry.setToWalletId(transfer ? req.getToWalletId() : null);
entry.setInstallmentMonths(installmentOf(req));
entry.setCurrency(req.getCurrency());
entry.setOriginalAmount(req.getOriginalAmount());
entry.setExchangeRate(req.getExchangeRate());
mapper.update(entry);
mapper.deleteEntryTagsByEntryId(id);
@@ -216,6 +284,19 @@ public class AccountService {
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
*/
/** 문자/푸시 텍스트만 파싱해 폼 자동채움용 결과 반환 (저장 안 함). iPhone 등 알림 자동수집 불가 대비. */
public ParsedEntryResponse parseText(NotificationRequest req) {
CardNotificationParser.Parsed p =
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
return ParsedEntryResponse.builder()
.recognized(p.isRecognized())
.type(p.isIncome() ? "INCOME" : "EXPENSE")
.amount(p.getAmount())
.merchant(p.getMerchant())
.date(p.getDate())
.build();
}
@Transactional
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
CardNotificationParser.Parsed p =
@@ -390,7 +471,7 @@ public class AccountService {
.toList();
}
/** 순자산 = 총자산(BANK) 총부채(CARD/LOAN) */
/** 순자산 = 총자산(BANK/CASH/INVEST) 총부채(CARD/LOAN) */
public NetWorthResponse netWorth(Long memberId) {
Map<Long, Long> balances = balanceMap(memberId);
Map<Long, InvestService.WalletInvest> inv = investService.valuationByWallet(memberId);
@@ -407,7 +488,7 @@ public class AccountService {
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
assets += bal + wi.cashDelta + wi.stockEval;
}
} else if ("BANK".equals(w.getType())) {
} else if ("BANK".equals(w.getType()) || "CASH".equals(w.getType())) {
assets += bal;
} else {
liabilities += -bal; // 부채는 음수 잔액 → 양수 부채로 표시
@@ -454,6 +535,12 @@ public class AccountService {
wallet.setOpeningBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L);
wallet.setOpeningDate(req.getOpeningDate());
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);
return WalletResponse.from(wallet, balanceMap(memberId).getOrDefault(id, wallet.getOpeningBalance()));
}
@@ -483,6 +570,7 @@ public class AccountService {
}
private Wallet toWallet(WalletRequest req) {
boolean isLoan = "LOAN".equals(req.getType());
return Wallet.builder()
.type(req.getType())
.name(req.getName().trim())
@@ -492,6 +580,11 @@ public class AccountService {
.openingBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L)
.openingDate(req.getOpeningDate())
.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();
}
@@ -0,0 +1,205 @@
package com.sb.web.account.service;
import com.sb.web.account.dto.AccountEntryRequest;
import com.sb.web.account.dto.AccountTagRequest;
import com.sb.web.account.dto.BudgetRequest;
import com.sb.web.account.dto.CategoryRequest;
import com.sb.web.account.dto.QuickEntryRequest;
import com.sb.web.account.dto.RecurringRequest;
import com.sb.web.account.dto.RestoreRequest;
import com.sb.web.account.dto.WalletRequest;
import com.sb.web.account.mapper.BackupMapper;
import com.sb.web.common.exception.ApiException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 데이터 가져오기(복구) — 기존 데이터를 전부 삭제하고 백업(이름 기준)을 새로 재생성한다.
* 전체를 한 트랜잭션으로 처리해 중간 실패 시 롤백(기존 데이터 보존)된다.
* ※ 투자 종목/매매내역은 백업에 포함되지 않아 복구 시 삭제된다(평가액 계좌 자체는 복원).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BackupService {
private final BackupMapper backupMapper;
private final AccountService accountService;
private final CategoryService categoryService;
private final RecurringService recurringService;
private final BudgetService budgetService;
private final QuickEntryService quickEntryService;
@Transactional
public void restore(Long memberId, RestoreRequest req) {
if (isEmpty(req)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "복구할 데이터가 비어 있습니다. 백업 파일을 확인해주세요.");
}
// 1) 기존 데이터 전부 삭제 (FK 안전 순서)
backupMapper.deleteEntryTags(memberId);
backupMapper.deleteEntries(memberId);
backupMapper.deleteRecurrings(memberId);
backupMapper.deleteBudgets(memberId);
backupMapper.deleteBudgetIncome(memberId);
backupMapper.deleteQuickEntries(memberId);
backupMapper.deleteInvestTrades(memberId);
backupMapper.deleteInvestHoldings(memberId);
backupMapper.deleteCategories(memberId);
backupMapper.deleteTags(memberId);
backupMapper.deleteWallets(memberId);
// 2) 계좌 → 원본 id(oldId) → 새 id (같은 이름 계좌가 있어도 정확)
Map<Long, Long> walletMap = new HashMap<>();
if (req.getWallets() != null) {
for (RestoreRequest.Wal w : req.getWallets()) {
if (w == null || w.getName() == null) continue;
WalletRequest wr = new WalletRequest();
wr.setType(w.getType());
wr.setName(w.getName());
wr.setIssuer(w.getIssuer());
wr.setAccountNumber(w.getAccountNumber());
wr.setCardType(w.getCardType());
wr.setOpeningBalance(w.getOpeningBalance());
wr.setOpeningDate(w.getOpeningDate());
wr.setCurrentValue(w.getCurrentValue());
Long newId = accountService.createWallet(wr, memberId).getId();
if (w.getOldId() != null) walletMap.put(w.getOldId(), newId);
}
}
// 3) 태그 → 이름→새 ID
Map<String, Long> tagMap = new HashMap<>();
if (req.getTags() != null) {
for (String name : req.getTags()) {
if (name == null || name.isBlank()) continue;
AccountTagRequest tr = new AccountTagRequest();
tr.setName(name.trim());
tagMap.put(name.trim(), accountService.createTag(tr, memberId).getId());
}
}
// 4) 분류 — 대분류 먼저, 소분류는 부모 이름으로 매핑 ("TYPE|name" → id)
Map<String, Long> catMap = new HashMap<>();
if (req.getCategories() != null) {
for (RestoreRequest.Cat c : req.getCategories()) {
if (c.getName() == null || (c.getParent() != null && !c.getParent().isBlank())) continue;
CategoryRequest cr = new CategoryRequest();
cr.setType(c.getType());
cr.setName(c.getName());
catMap.put(key(c.getType(), c.getName()), categoryService.create(cr, memberId).getId());
}
for (RestoreRequest.Cat c : req.getCategories()) {
if (c.getName() == null || c.getParent() == null || c.getParent().isBlank()) continue;
CategoryRequest cr = new CategoryRequest();
cr.setType(c.getType());
cr.setName(c.getName());
cr.setParentId(catMap.get(key(c.getType(), c.getParent())));
categoryService.create(cr, memberId);
}
}
// 5) 내역
if (req.getEntries() != null) {
for (RestoreRequest.Entry e : req.getEntries()) {
if (e.getEntryDate() == null || e.getType() == null) continue;
Long wid = walletMap.get(e.getWalletId());
Long twid = walletMap.get(e.getToWalletId());
if (badTransfer(e.getType(), wid, twid)) continue; // 이체 계좌가 없거나 같으면 건너뜀
AccountEntryRequest er = new AccountEntryRequest();
er.setEntryDate(e.getEntryDate());
er.setType(e.getType());
er.setCategory(e.getCategory());
er.setAmount(e.getAmount());
er.setMemo(e.getMemo());
er.setWalletId(wid);
er.setToWalletId(twid);
er.setInstallmentMonths(e.getInstallmentMonths());
if (e.getTags() != null) {
er.setTagIds(e.getTags().stream().map(tagMap::get).filter(Objects::nonNull).toList());
}
accountService.create(er, memberId);
}
}
// 6) 고정지출
if (req.getRecurrings() != null) {
for (RestoreRequest.Recur r : req.getRecurrings()) {
if (r.getTitle() == null) continue;
Long wid = walletMap.get(r.getWalletId());
Long twid = walletMap.get(r.getToWalletId());
if (badTransfer(r.getType(), wid, twid)) continue;
RecurringRequest rr = new RecurringRequest();
rr.setTitle(r.getTitle());
rr.setType(r.getType());
rr.setAmount(r.getAmount());
rr.setCategory(r.getCategory());
rr.setMemo(r.getMemo());
rr.setWalletId(wid);
rr.setToWalletId(twid);
rr.setFrequency(r.getFrequency());
rr.setDayOfMonth(r.getDayOfMonth());
rr.setDayOfWeek(r.getDayOfWeek());
rr.setMonthOfYear(r.getMonthOfYear());
rr.setStartDate(r.getStartDate());
rr.setEndDate(r.getEndDate());
rr.setActive(r.getActive() == null || r.getActive());
recurringService.create(rr, memberId);
}
}
// 7) 예산 — 복구 시 현재 월 예산으로 등록(백업엔 월 정보 없음)
if (req.getBudgets() != null) {
java.time.LocalDate today = java.time.LocalDate.now();
for (BudgetRequest b : req.getBudgets()) {
if (b == null || b.getCategory() == null) continue;
budgetService.create(b, memberId, today.getYear(), today.getMonthValue());
}
}
// 8) 자주 쓰는 내역
if (req.getQuickEntries() != null) {
for (RestoreRequest.Quick q : req.getQuickEntries()) {
if (q.getType() == null) continue;
QuickEntryRequest qr = new QuickEntryRequest();
qr.setLabel(q.getLabel());
qr.setType(q.getType());
qr.setCategory(q.getCategory());
qr.setAmount(q.getAmount());
qr.setMemo(q.getMemo());
qr.setWalletId(walletMap.get(q.getWalletId()));
quickEntryService.create(qr, memberId);
}
}
log.info("[restore] member={} wallets={} entries={} restored",
memberId, walletMap.size(), req.getEntries() == null ? 0 : req.getEntries().size());
}
private static String key(String type, String name) {
return type + "|" + name;
}
/** 이체인데 출금/입금 계좌가 없거나 같으면 복구에서 제외(검증 오류로 전체 롤백 방지) */
private static boolean badTransfer(String type, Long walletId, Long toWalletId) {
return "TRANSFER".equals(type) && (walletId == null || toWalletId == null || walletId.equals(toWalletId));
}
private boolean isEmpty(RestoreRequest r) {
return empty(r.getWallets()) && empty(r.getCategories()) && empty(r.getTags())
&& empty(r.getEntries()) && empty(r.getRecurrings()) && empty(r.getBudgets())
&& empty(r.getQuickEntries());
}
private boolean empty(List<?> l) {
return l == null || l.isEmpty();
}
}
@@ -31,36 +31,46 @@ public class BudgetService {
private final BudgetMapper budgetMapper;
private final AccountEntryMapper entryMapper;
public List<BudgetResponse> list(Long memberId) {
return budgetMapper.findByMember(memberId).stream().map(BudgetResponse::from).toList();
public List<BudgetResponse> list(Long memberId, int year, int month) {
return budgetMapper.findByMember(memberId, year, month).stream().map(BudgetResponse::from).toList();
}
@Transactional
public BudgetResponse create(BudgetRequest req, Long memberId) {
public BudgetResponse create(BudgetRequest req, Long memberId, int year, int month) {
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, "이미 예산이 설정된 카테고리입니다.");
}
Budget budget = toBudget(req, category);
budget.setMemberId(memberId);
budget.setYear(year);
budget.setMonth(month);
budgetMapper.insert(budget);
return BudgetResponse.from(budget);
}
@Transactional
public BudgetResponse update(Long id, BudgetRequest req, Long memberId) {
mustFind(id, memberId);
Budget existing = mustFind(id, memberId);
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, "이미 예산이 설정된 카테고리입니다.");
}
Budget budget = toBudget(req, category);
budget.setId(id);
budget.setMemberId(memberId);
budget.setYear(existing.getYear());
budget.setMonth(existing.getMonth());
budgetMapper.update(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
public void delete(Long id, Long memberId) {
mustFind(id, memberId);
@@ -79,7 +89,7 @@ public class BudgetService {
}
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 spent = spentByCategory.getOrDefault(b.getCategory(), 0L);
result.add(BudgetStatus.builder()
@@ -103,7 +113,8 @@ public class BudgetService {
int y = year != null ? year : Year.now().getValue();
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<>();
for (Map<String, Object> row : entryMapper.statsByPeriod(memberId, unit, year, month)) {
expense.put(String.valueOf(((Number) row.get("bucket")).longValue()),
@@ -1,21 +1,25 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.AccountCategory;
import com.sb.web.account.domain.DefaultCategory;
import com.sb.web.account.dto.CategoryRequest;
import com.sb.web.account.dto.CategoryResponse;
import com.sb.web.account.mapper.AccountEntryMapper;
import com.sb.web.account.mapper.BudgetMapper;
import com.sb.web.account.mapper.CategoryMapper;
import com.sb.web.account.mapper.DefaultCategoryMapper;
import com.sb.web.common.exception.ApiException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기분류 불러오기.
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기분류 불러오기.
*/
@Service
@RequiredArgsConstructor
@@ -24,6 +28,7 @@ public class CategoryService {
private final CategoryMapper categoryMapper;
private final AccountEntryMapper entryMapper;
private final BudgetMapper budgetMapper;
private final DefaultCategoryMapper defaultCategoryMapper;
public List<CategoryResponse> list(Long memberId) {
return categoryMapper.findByMember(memberId).stream().map(CategoryResponse::from).toList();
@@ -35,14 +40,30 @@ public class CategoryService {
if (categoryMapper.countByName(memberId, req.getType(), name, null) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
}
validateParent(req.getParentId(), req.getType(), memberId);
Integer max = categoryMapper.maxSortOrder(memberId, req.getType());
int order = (max == null ? 0 : max + 1);
AccountCategory c = AccountCategory.builder()
.memberId(memberId).type(req.getType()).name(name).sortOrder(order).build();
.memberId(memberId).type(req.getType()).name(name)
.parentId(req.getParentId()).sortOrder(order).build();
categoryMapper.insert(c);
return CategoryResponse.from(c);
}
/** 대분류 지정 검증: null=대분류 / 값=대분류여야(2단계 제한·동일타입·본인) */
private void validateParent(Long parentId, String type, Long memberId) {
if (parentId == null) {
return;
}
AccountCategory parent = categoryMapper.findByIdAndMember(parentId, memberId);
if (parent == null || !parent.getType().equals(type)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류가 올바르지 않습니다.");
}
if (parent.getParentId() != null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류 아래에만 소분류를 둘 수 있습니다(2단계).");
}
}
/** 드래그앤드랍 순서 변경: 전달된 id 순서대로 sort_order 재설정 (본인·동일 타입만) */
@Transactional
public List<CategoryResponse> reorder(Long memberId, String type, List<Long> ids) {
@@ -56,7 +77,8 @@ public class CategoryService {
return list(memberId);
}
/** 이름 변경 가능. 변경 시 기존 내역·예산의 분류명도 함께 갱신 */
/** 이름 변경 + 대분류 이동. 이름 변경 시 기존 내역·예산의 분류명도 함께 갱신.
* 주의: parentId 는 항상 현재값(또는 새 대분류)을 보내야 한다(미전송 시 대분류로 풀림). */
@Transactional
public CategoryResponse update(Long id, CategoryRequest req, Long memberId) {
AccountCategory c = mustFind(id, memberId);
@@ -64,8 +86,20 @@ public class CategoryService {
if (categoryMapper.countByName(memberId, c.getType(), newName, id) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
}
Long newParent = req.getParentId();
if (newParent != null) {
if (newParent.equals(id)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "자기 자신을 대분류로 지정할 수 없습니다.");
}
// 소분류를 가진 대분류는 소분류로 바꿀 수 없음(3단계 방지)
if (categoryMapper.countChildren(memberId, id) > 0) {
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 소분류로 바꿀 수 없습니다.");
}
}
validateParent(newParent, c.getType(), memberId);
String oldName = c.getName();
c.setName(newName);
c.setParentId(newParent);
categoryMapper.update(c);
if (!oldName.equals(newName)) {
entryMapper.renameCategory(memberId, c.getType(), oldName, newName);
@@ -79,21 +113,48 @@ public class CategoryService {
@Transactional
public void delete(Long id, Long memberId) {
mustFind(id, memberId);
if (categoryMapper.countChildren(memberId, id) > 0) {
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 삭제할 수 없습니다. 소분류를 먼저 옮기거나 삭제하세요.");
}
categoryMapper.delete(id, memberId);
}
/** 기존 내역에 쓰인 분류명을 분류 목록으로 가져오기 (중복 무시) */
/** 관리자가 정의한 기본(디폴트) 분류를 내 분류로 복사 (대/소분류 계층 유지, 같은 이름은 건너뜀) */
@Transactional
public List<CategoryResponse> importFromEntries(Long memberId) {
for (String type : new String[]{"INCOME", "EXPENSE"}) {
for (String name : entryMapper.findDistinctCategories(memberId, type)) {
categoryMapper.insertIgnore(AccountCategory.builder()
.memberId(memberId).type(type).name(name).sortOrder(0).build());
public List<CategoryResponse> importDefaults(Long memberId) {
List<DefaultCategory> defaults = defaultCategoryMapper.findAll();
// 1) 대분류 먼저 생성/확보 → default.id → 내 분류.id 매핑
Map<Long, Long> majorMap = new HashMap<>();
for (DefaultCategory d : defaults) {
if (d.getParentId() != null) {
continue;
}
majorMap.put(d.getId(), ensureCategory(memberId, d.getType(), d.getName(), null));
}
// 2) 소분류 생성 (매핑된 대분류 아래; 부모 매핑이 없으면 대분류로 취급)
for (DefaultCategory d : defaults) {
if (d.getParentId() == null) {
continue;
}
ensureCategory(memberId, d.getType(), d.getName(), majorMap.get(d.getParentId()));
}
return list(memberId);
}
/** 같은 이름 분류가 있으면 그 id, 없으면 생성해서 id 반환 */
private Long ensureCategory(Long memberId, String type, String name, Long parentId) {
AccountCategory existing = categoryMapper.findByMemberTypeName(memberId, type, name);
if (existing != null) {
return existing.getId();
}
Integer max = categoryMapper.maxSortOrder(memberId, type);
AccountCategory c = AccountCategory.builder()
.memberId(memberId).type(type).name(name).parentId(parentId)
.sortOrder(max == null ? 0 : max + 1).build();
categoryMapper.insert(c);
return c.getId();
}
private AccountCategory mustFind(Long id, Long memberId) {
AccountCategory c = categoryMapper.findByIdAndMember(id, memberId);
if (c == null) {
@@ -0,0 +1,75 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.DefaultCategory;
import com.sb.web.account.mapper.DefaultCategoryMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 기본(디폴트) 분류 초기 시드. default_category 가 비어 있을 때만 한 번 생성한다.
* (관리자가 이후 자유롭게 수정/삭제 가능 — 비어 있을 때만 채우므로 운영 데이터를 덮어쓰지 않음)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DefaultCategorySeeder implements ApplicationRunner {
private final DefaultCategoryMapper mapper;
// 대분류 → 소분류 목록 (입력 순서 유지)
private static final Map<String, List<String>> EXPENSE = new LinkedHashMap<>();
private static final Map<String, List<String>> INCOME = new LinkedHashMap<>();
static {
EXPENSE.put("식비", List.of("외식", "식료품", "카페/간식", "배달"));
EXPENSE.put("교통", List.of("대중교통", "택시", "주유", "주차/통행료"));
EXPENSE.put("주거/통신", List.of("월세/관리비", "공과금", "통신비", "인터넷"));
EXPENSE.put("생활", List.of("생필품", "의류/미용", "가구/가전"));
EXPENSE.put("건강/의료", List.of("병원", "약국", "운동"));
EXPENSE.put("문화/여가", List.of("영화/공연", "여행", "취미", "구독서비스"));
EXPENSE.put("교육", List.of("학원", "도서", "강의"));
EXPENSE.put("경조사", List.of("축의금", "부의금", "선물"));
EXPENSE.put("금융", List.of("보험", "대출이자", "수수료"));
EXPENSE.put("기타", List.of());
INCOME.put("급여", List.of("월급", "상여금"));
INCOME.put("부수입", List.of("용돈", "이자/배당", "환급"));
INCOME.put("기타수입", List.of("중고판매", "기타"));
}
@Override
@Transactional
public void run(ApplicationArguments args) {
if (!mapper.findAll().isEmpty()) {
return; // 이미 데이터가 있으면 시드하지 않음
}
int n = seed("EXPENSE", EXPENSE) + seed("INCOME", INCOME);
log.info("[default-category] 기본 분류 시드 생성: {}건", n);
}
private int seed(String type, Map<String, List<String>> tree) {
int count = 0;
int majorOrder = 0;
for (Map.Entry<String, List<String>> e : tree.entrySet()) {
DefaultCategory major = DefaultCategory.builder()
.type(type).name(e.getKey()).parentId(null).sortOrder(majorOrder++).build();
mapper.insert(major); // 생성키(id) 채워짐
count++;
int subOrder = 0;
for (String child : e.getValue()) {
mapper.insert(DefaultCategory.builder()
.type(type).name(child).parentId(major.getId()).sortOrder(subOrder++).build());
count++;
}
}
return count;
}
}
@@ -0,0 +1,114 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.DefaultCategory;
import com.sb.web.account.dto.CategoryReorderRequest;
import com.sb.web.account.dto.CategoryRequest;
import com.sb.web.account.dto.CategoryResponse;
import com.sb.web.account.mapper.DefaultCategoryMapper;
import com.sb.web.common.exception.ApiException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 기본(디폴트) 분류 서비스 — 전체 공용 템플릿(관리자 전용 CRUD). 2단계(대/소분류) 제한.
*/
@Service
@RequiredArgsConstructor
public class DefaultCategoryService {
private final DefaultCategoryMapper mapper;
public List<CategoryResponse> list() {
return mapper.findAll().stream().map(this::toResponse).toList();
}
@Transactional
public CategoryResponse create(CategoryRequest req) {
String name = req.getName().trim();
if (mapper.countByName(req.getType(), name, null) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
}
validateParent(req.getParentId(), req.getType());
Integer max = mapper.maxSortOrder(req.getType());
DefaultCategory c = DefaultCategory.builder()
.type(req.getType()).name(name).parentId(req.getParentId())
.sortOrder(max == null ? 0 : max + 1).build();
mapper.insert(c);
return toResponse(c);
}
@Transactional
public CategoryResponse update(Long id, CategoryRequest req) {
DefaultCategory c = mustFind(id);
String newName = req.getName().trim();
if (mapper.countByName(c.getType(), newName, id) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
}
Long newParent = req.getParentId();
if (newParent != null) {
if (newParent.equals(id)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "자기 자신을 대분류로 지정할 수 없습니다.");
}
if (mapper.countChildren(id) > 0) {
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 소분류로 바꿀 수 없습니다.");
}
}
validateParent(newParent, c.getType());
c.setName(newName);
c.setParentId(newParent);
mapper.update(c);
return toResponse(c);
}
@Transactional
public void delete(Long id) {
mustFind(id);
if (mapper.countChildren(id) > 0) {
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 삭제할 수 없습니다. 소분류를 먼저 옮기거나 삭제하세요.");
}
mapper.delete(id);
}
@Transactional
public List<CategoryResponse> reorder(CategoryReorderRequest req) {
int i = 0;
for (Long id : req.getIds()) {
DefaultCategory c = mapper.findById(id);
if (c != null && c.getType().equals(req.getType())) {
mapper.updateSortOrder(id, i++);
}
}
return list();
}
private void validateParent(Long parentId, String type) {
if (parentId == null) {
return;
}
DefaultCategory parent = mapper.findById(parentId);
if (parent == null || !parent.getType().equals(type)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류가 올바르지 않습니다.");
}
if (parent.getParentId() != null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류 아래에만 소분류를 둘 수 있습니다(2단계).");
}
}
private DefaultCategory mustFind(Long id) {
DefaultCategory c = mapper.findById(id);
if (c == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "기본 분류를 찾을 수 없습니다.");
}
return c;
}
private CategoryResponse toResponse(DefaultCategory c) {
return CategoryResponse.builder()
.id(c.getId()).type(c.getType()).name(c.getName()).parentId(c.getParentId())
.build();
}
}
@@ -0,0 +1,64 @@
package com.sb.web.account.service;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 환율 조회. open.er-api.com(무료·무인증) 사용. 통화(base)별로 하루 1회만 호출하고 캐시.
* 실패해도 예외를 던지지 않고 null(또는 직전 캐시) 반환.
*/
@Slf4j
@Service
public class FxService {
private static final String URL = "https://open.er-api.com/v6/latest/{base}";
private final RestClient restClient = RestClient.builder().build();
private record Cached(LocalDate date, JsonNode rates) {}
private final Map<String, Cached> cache = new ConcurrentHashMap<>();
/** from 통화 1단위 → to 통화 환율. 조회 실패 시 null. */
public BigDecimal getRate(String from, String to) {
if (from == null || to == null) return null;
String base = from.trim().toUpperCase();
String target = to.trim().toUpperCase();
if (base.isEmpty() || target.isEmpty()) return null;
if (base.equals(target)) return BigDecimal.ONE;
JsonNode rates = ratesFor(base);
if (rates == null) return null;
JsonNode r = rates.get(target);
return (r != null && r.isNumber()) ? r.decimalValue() : null;
}
private JsonNode ratesFor(String base) {
Cached c = cache.get(base);
LocalDate today = LocalDate.now();
if (c != null && c.date().equals(today)) return c.rates();
try {
JsonNode root = restClient.get()
.uri(URL, base)
.header("User-Agent", "Mozilla/5.0")
.retrieve()
.body(JsonNode.class);
if (root == null || !"success".equals(root.path("result").asText())) {
return c != null ? c.rates() : null;
}
JsonNode rates = root.path("rates");
if (rates.isMissingNode() || !rates.isObject()) {
return c != null ? c.rates() : null;
}
cache.put(base, new Cached(today, rates));
return rates;
} catch (Exception e) {
log.warn("환율 조회 실패 base={}: {}", base, e.toString());
return c != null ? c.rates() : null; // 실패 시 직전 캐시라도
}
}
}
@@ -0,0 +1,56 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.QuickEntry;
import com.sb.web.account.dto.QuickEntryRequest;
import com.sb.web.account.dto.QuickEntryResponse;
import com.sb.web.account.mapper.QuickEntryMapper;
import com.sb.web.common.exception.ApiException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 자주 쓰는 내역(빠른 등록 템플릿) 서비스 — 사용자별.
*/
@Service
@RequiredArgsConstructor
public class QuickEntryService {
private final QuickEntryMapper mapper;
public List<QuickEntryResponse> list(Long memberId) {
return mapper.findByMember(memberId).stream().map(QuickEntryResponse::from).toList();
}
@Transactional
public QuickEntryResponse create(QuickEntryRequest req, Long memberId) {
Integer max = mapper.maxSortOrder(memberId);
QuickEntry e = QuickEntry.builder()
.memberId(memberId)
.label(blankToNull(req.getLabel()))
.type(req.getType())
.category("EXPENSE".equals(req.getType()) || "INCOME".equals(req.getType()) ? blankToNull(req.getCategory()) : null)
.amount(req.getAmount())
.memo(blankToNull(req.getMemo()))
.walletId(req.getWalletId())
.sortOrder(max == null ? 0 : max + 1)
.build();
mapper.insert(e);
return QuickEntryResponse.from(mapper.findByIdAndMember(e.getId(), memberId));
}
@Transactional
public void delete(Long id, Long memberId) {
if (mapper.findByIdAndMember(id, memberId) == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "자주 쓰는 내역을 찾을 수 없습니다.");
}
mapper.delete(id, memberId);
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}
@@ -83,15 +83,19 @@ public class RecurringService {
private AccountEntry buildEntry(Recurring r, LocalDate date, Long memberId) {
boolean transfer = "TRANSFER".equals(r.getType());
// 메모가 없으면 제목으로 채워 '확인 필요' 목록에서 알아보기 쉽게 한다.
String memo = (r.getMemo() != null && !r.getMemo().isBlank()) ? r.getMemo() : r.getTitle();
return AccountEntry.builder()
.memberId(memberId)
.entryDate(date)
.type(r.getType())
.category(transfer ? null : r.getCategory())
.amount(r.getAmount())
.memo(r.getMemo())
.memo(memo)
.walletId(r.getWalletId())
.toWalletId(transfer ? r.getToWalletId() : null)
// 실제 결제/이체가 일어났는지 사용자가 확인하도록 '확인 필요'로 생성
.pending(true)
.build();
}
@@ -0,0 +1,55 @@
package com.sb.web.admin.controller;
import com.sb.web.account.dto.CategoryReorderRequest;
import com.sb.web.account.dto.CategoryRequest;
import com.sb.web.account.dto.CategoryResponse;
import com.sb.web.account.service.DefaultCategoryService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 관리자 기본(디폴트) 분류 관리 API. (/api/admin/** — AdminInterceptor 로 ADMIN 권한 필요)
* GET /default-categories 기본 분류 목록
* POST /default-categories 생성(대/소분류)
* PUT /default-categories/{id} 수정(이름·대분류)
* DELETE /default-categories/{id} 삭제
* PUT /default-categories/reorder 순서 변경
*/
@RestController
@RequestMapping("/api/admin/default-categories")
@RequiredArgsConstructor
public class AdminCategoryController {
private final DefaultCategoryService service;
@GetMapping
public List<CategoryResponse> list() {
return service.list();
}
@PostMapping
public ResponseEntity<CategoryResponse> create(@Valid @RequestBody CategoryRequest req) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req));
}
@PutMapping("/{id}")
public CategoryResponse update(@PathVariable Long id, @Valid @RequestBody CategoryRequest req) {
return service.update(id, req);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@PutMapping("/reorder")
public List<CategoryResponse> reorder(@RequestBody CategoryReorderRequest req) {
return service.reorder(req);
}
}
@@ -1,7 +1,5 @@
package com.sb.web.admin.controller;
import com.sb.web.board.dto.BoardSettingRequest;
import com.sb.web.board.dto.BoardSettingResponse;
import com.sb.web.board.dto.TagCategoryRequest;
import com.sb.web.board.dto.TagCategoryResponse;
import com.sb.web.board.dto.TagRequest;
@@ -68,16 +66,4 @@ public class AdminTagController {
tagService.deleteTag(id);
return ResponseEntity.noContent().build();
}
/* ===== 게시판 설정 (사용할 태그 카테고리) ===== */
@GetMapping("/board-setting")
public BoardSettingResponse boardSetting() {
return tagService.getBoardSetting();
}
@PutMapping("/board-setting")
public BoardSettingResponse updateBoardSetting(@RequestBody BoardSettingRequest req) {
return tagService.setBoardSetting(req.getTagCategoryId());
}
}
@@ -1,5 +1,6 @@
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.StatusUpdateRequest;
import com.sb.web.admin.service.MemberAdminService;
@@ -36,6 +37,14 @@ public class MemberAdminController {
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")
public MemberAdminResponse updateStatus(
@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);
}
@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
public MemberAdminResponse updateStatus(Long targetId, String status, Long currentAdminId) {
Member target = mustFind(targetId);
@@ -29,6 +29,7 @@ public class AuthController {
private final AuthService authService;
private final com.sb.web.admin.service.AppSettingService appSettingService;
private final com.sb.web.point.PointService pointService;
/** 회원가입 허용 여부 (비보호) — 프론트가 가입 진입 전에 차단 표시용 */
@GetMapping("/signup-enabled")
@@ -56,6 +57,18 @@ public class AuthController {
return authService.login(req, clientIp(request));
}
/** 구글 OAuth 클라이언트 ID (비보호) — 프론트가 구글 버튼 노출/초기화에 사용. 미설정 시 빈 문자열 */
@GetMapping("/google-client-id")
public java.util.Map<String, String> googleClientId() {
return java.util.Map.of("clientId", authService.googleClientId());
}
/** 구글 로그인/가입 (비보호) — ID 토큰 검증 후 세션 발급 */
@PostMapping("/google")
public LoginResponse googleLogin(@Valid @RequestBody com.sb.web.auth.dto.GoogleLoginRequest req) {
return authService.googleLogin(req.getIdToken(), req.isRememberMe());
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest request) {
authService.logout(AuthInterceptor.resolveToken(request));
@@ -67,6 +80,29 @@ public class AuthController {
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")
public ResponseEntity<Void> changePassword(
@Valid @RequestBody PasswordChangeRequest req,
@@ -84,6 +120,12 @@ public class AuthController {
return ResponseEntity.noContent().build();
}
/** 현재 회원 전체 프로필 (아바타·포인트 포함) — 재로그인 없이 최신값 동기화 */
@GetMapping("/profile")
public MemberResponse profile(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return authService.getProfile(current.getId());
}
/** 가입정보(이름/이메일) 변경 */
@PutMapping("/profile")
public MemberResponse updateProfile(
@@ -92,4 +134,12 @@ public class AuthController {
HttpServletRequest request) {
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 name;
private String role;
private String plan;
private String provider;
private boolean rememberMe;
private LocalDateTime expiresAt;
@@ -34,6 +35,7 @@ public class AuthSession {
.loginId(u.getLoginId())
.name(u.getName())
.role(u.getRole())
.plan(u.getPlan())
.provider(u.getProvider())
.rememberMe(u.isRememberMe())
.expiresAt(expiresAt)
@@ -46,6 +48,7 @@ public class AuthSession {
.loginId(loginId)
.name(name)
.role(role)
.plan(plan)
.provider(provider)
.rememberMe(rememberMe)
.build();
@@ -23,9 +23,17 @@ public class Member implements Serializable {
private String password; // BCrypt 해시 (소셜 전용은 null)
private String name;
private String email;
private String provider; // LOCAL / NAVER
private String provider; // LOCAL / NAVER / GOOGLE
private String providerId; // 소셜 제공자 측 고유 ID
private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지)
private String googlePicture; // 구글 아바타 URL (로그인 시 동기화)
private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
private String role; // USER / ADMIN
private String 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 LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -0,0 +1,16 @@
package com.sb.web.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 구글 로그인 요청 — 프론트(Google Identity Services)에서 받은 ID 토큰.
*/
@Data
public class GoogleLoginRequest {
@NotBlank(message = "토큰이 없습니다.")
private String idToken;
private boolean rememberMe;
}
@@ -18,6 +18,7 @@ public class MemberAdminResponse {
private String name;
private String email;
private String role; // USER / ADMIN
private String plan; // FREE / PREMIUM
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
private String provider; // LOCAL / 소셜
private LocalDateTime createdAt;
@@ -29,6 +30,7 @@ public class MemberAdminResponse {
.name(m.getName())
.email(m.getEmail())
.role(m.getRole())
.plan(m.getPlan())
.status(m.getStatus())
.provider(m.getProvider())
.createdAt(m.getCreatedAt())
@@ -17,6 +17,13 @@ public class MemberResponse {
private String email;
private String provider;
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) {
return MemberResponse.builder()
@@ -26,6 +33,13 @@ public class MemberResponse {
.email(m.getEmail())
.provider(m.getProvider())
.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();
}
}
@@ -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 name;
private String role;
private String plan; // FREE / PREMIUM
private String provider;
private boolean rememberMe; // 자동 로그인 세션 여부 (슬라이딩 만료 TTL 결정용)
@@ -31,6 +32,7 @@ public class SessionUser implements Serializable {
.loginId(m.getLoginId())
.name(m.getName())
.role(m.getRole())
.plan(m.getPlan())
.provider(m.getProvider())
.build();
}
@@ -21,6 +21,9 @@ public interface AuthSessionMapper {
/** 프로필 변경 시 백업 세션의 표시 이름 동기화 */
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 deleteExpired(@Param("now") LocalDateTime now);
@@ -22,6 +22,15 @@ public interface MemberMapper {
Member findByProvider(@Param("provider") String provider,
@Param("providerId") String providerId);
/** 구글 sub 로 회원 조회 (연결된 LOCAL 계정 + 순수 구글 계정 모두). */
Member findByGoogleId(@Param("googleId") String googleId);
/** 구글 로그인 시 연결 대상이 될 같은 이메일의 기존 계정(LOCAL 우선, 미연결만). */
Member findByEmailForLink(@Param("email") String email);
/** 기존 계정에 구글 sub 연결 (provider 유지). */
int linkGoogle(@Param("id") Long id, @Param("googleId") String googleId);
int countByLoginId(@Param("loginId") String loginId);
int insert(Member member);
@@ -31,8 +40,27 @@ public interface MemberMapper {
/** 프로필(이름/이메일) 변경 */
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 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 deleteById(@Param("id") Long id);
@@ -0,0 +1,35 @@
package com.sb.web.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 회원 탈퇴 시 가계부(BackupMapper) 외의 사용자 데이터 정리 — 게시판/포인트/결제/세션.
*/
@Mapper
public interface WithdrawMapper {
// 게시판 — 내가 누른 표
int deletePostVotesByMember(@Param("memberId") Long memberId);
int deleteCommentVotesByMember(@Param("memberId") Long memberId);
// 게시판 — 내 글에 달린 것들 (남이 단 댓글/표 포함)
int deleteCommentVotesOnMyPosts(@Param("memberId") Long memberId);
int deletePostVotesOnMyPosts(@Param("memberId") Long memberId);
int deletePostTagsOnMyPosts(@Param("memberId") Long memberId);
int deleteCommentsOnMyPosts(@Param("memberId") Long memberId);
// 게시판 — 내 댓글(남의 글에 단 것)과 그 표
int deleteCommentVotesOnMyComments(@Param("memberId") Long memberId);
int deleteCommentsByAuthor(@Param("memberId") Long memberId);
// 게시판 — 내 글
int deletePostsByAuthor(@Param("memberId") Long memberId);
int deleteBoardImagesByMember(@Param("memberId") Long memberId);
// 신고 / 포인트 / 결제 / 세션
int deleteReportsByMember(@Param("memberId") Long memberId);
int deletePointHistory(@Param("memberId") Long memberId);
int deleteIapPurchases(@Param("memberId") Long memberId);
int deleteAuthSessions(@Param("memberId") Long memberId);
}
@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
/**
@@ -37,6 +38,12 @@ public class AuthService {
private final RedisTemplate<String, Object> redisTemplate;
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.WithdrawMapper withdrawMapper;
private final com.sb.web.account.mapper.BackupMapper backupMapper;
/** 구글 OAuth 클라이언트 ID (ID 토큰 aud 검증용). 미설정 시 구글 로그인 비활성. */
@org.springframework.beans.factory.annotation.Value("${app.google-client-id:}")
private String googleClientId;
private static final String SESSION_PREFIX = "session:";
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
@@ -126,9 +133,14 @@ public class AuthService {
try { redisTemplate.delete("login:rl:" + clientIp); } catch (Exception ignore) { /* 무시 */ }
}
Duration ttl = req.isRememberMe() ? REMEMBER_TTL : SESSION_TTL;
return issueSession(member, req.isRememberMe());
}
/** 세션 토큰 발급 → Redis + DB 백업. login/소셜로그인 공용. */
private LoginResponse issueSession(Member member, boolean rememberMe) {
Duration ttl = rememberMe ? REMEMBER_TTL : SESSION_TTL;
SessionUser session = SessionUser.from(member);
session.setRememberMe(req.isRememberMe());
session.setRememberMe(rememberMe);
String token = UUID.randomUUID().toString().replace("-", "");
java.time.LocalDateTime expiresAt = java.time.LocalDateTime.now().plus(ttl);
@@ -137,9 +149,9 @@ public class AuthService {
} catch (Exception e) {
log.warn("[login] Redis 세션 저장 실패(무시, DB 백업 사용): {}", e.toString());
}
// 영속 백업 — Redis 재시작/유실에도 로그인 유지
authSessionMapper.insert(com.sb.web.auth.domain.AuthSession.of(token, session, expiresAt));
log.info("[login] {} (token issued, rememberMe={})", member.getLoginId(), req.isRememberMe());
log.info("[login] member={} provider={} (token issued, rememberMe={})",
member.getId(), member.getProvider(), rememberMe);
return LoginResponse.builder()
.token(token)
@@ -148,6 +160,87 @@ public class AuthService {
.build();
}
/** 구글 로그인/가입 — ID 토큰 검증 후 provider=GOOGLE 회원 조회/생성하고 세션 발급. */
@Transactional
public LoginResponse googleLogin(String idToken, boolean rememberMe) {
if (googleClientId == null || googleClientId.isBlank()) {
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "구글 로그인이 설정되지 않았습니다.");
}
if (idToken == null || idToken.isBlank()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "토큰이 없습니다.");
}
// 구글 ID 토큰 검증(서명·만료는 구글이 검증) → 클레임 반환
Map<?, ?> info;
try {
info = org.springframework.web.client.RestClient.create()
.get()
.uri("https://oauth2.googleapis.com/tokeninfo?id_token={t}", idToken)
.retrieve()
.body(Map.class);
} catch (Exception e) {
throw new ApiException(HttpStatus.UNAUTHORIZED, "구글 인증에 실패했습니다.");
}
if (info == null || !googleClientId.equals(String.valueOf(info.get("aud")))) {
throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 구글 토큰입니다.");
}
String sub = String.valueOf(info.get("sub"));
String email = info.get("email") == null ? null : String.valueOf(info.get("email"));
boolean emailVerified = "true".equals(String.valueOf(info.get("email_verified")));
String name = info.get("name") != null ? String.valueOf(info.get("name"))
: (email != null ? email.split("@")[0] : "사용자");
String picture = info.get("picture") != null ? String.valueOf(info.get("picture")) : null;
// 1) 구글 sub 로 이미 연결/가입된 계정 조회
Member member = memberMapper.findByGoogleId(sub);
// 2) 없으면 같은 (검증된)이메일의 기존 계정에 구글 연결 — 중복 계정/데이터 분리 방지
if (member == null && email != null && !email.isBlank() && emailVerified) {
Member existing = memberMapper.findByEmailForLink(email);
if (existing != null) {
if (!"ACTIVE".equals(existing.getStatus())) {
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
}
memberMapper.linkGoogle(existing.getId(), sub);
existing.setGoogleId(sub);
member = existing;
log.info("[google] linked to existing member id={} provider={} email={}",
member.getId(), member.getProvider(), email);
}
}
// 3) 그래도 없으면 신규 구글 계정 생성(최초 로그인 = 가입). 가입 제한 시 차단.
if (member == null) {
if (!appSettingService.isSignupEnabled()) {
throw new ApiException(HttpStatus.FORBIDDEN, "현재 회원가입이 제한되어 있습니다.");
}
member = Member.builder()
.name(name)
.email(email)
.provider("GOOGLE")
.providerId(sub)
.googleId(sub)
.googlePicture(picture)
.role("USER")
.status("ACTIVE")
.build();
memberMapper.insert(member);
log.info("[google] new member id={} email={}", member.getId(), email);
}
if (!"ACTIVE".equals(member.getStatus())) {
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
}
// 구글 아바타가 바뀌었으면 동기화(신규 가입은 이미 반영됨 → 변경 없을 때 불필요한 UPDATE 생략)
if (picture != null && !picture.equals(member.getGooglePicture())) {
memberMapper.updateGooglePicture(member.getId(), picture);
member.setGooglePicture(picture);
}
return issueSession(member, rememberMe);
}
public String googleClientId() {
return googleClientId == null ? "" : googleClientId;
}
/**
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
@@ -198,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시) */
@org.springframework.scheduling.annotation.Scheduled(cron = "0 0 4 * * *")
public void cleanupExpiredSessions() {
@@ -228,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 백업)의 표시 이름도 동기화한다.
@@ -251,6 +398,31 @@ public class AuthService {
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 백업)의 표시 이름을 맞춘다. */
private void syncSessionName(String token, String name) {
if (token == null || token.isBlank()) {
@@ -277,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.PostRequest;
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.VoteRequest;
import com.sb.web.board.dto.VoteResponse;
import com.sb.web.board.service.BoardService;
import com.sb.web.board.service.TagService;
import jakarta.validation.Valid;
@@ -50,6 +53,24 @@ public class BoardController {
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}")
public PostDetail get(
@PathVariable Long id,
@@ -99,15 +120,41 @@ public class BoardController {
return ResponseEntity.noContent().build();
}
@GetMapping("/tags")
public List<String> tags() {
return boardService.allTags();
/** 공지 등록 (관리자) — 목록 최상단 고정 */
@PostMapping("/posts/{id}/notice")
public ResponseEntity<Void> notice(
@PathVariable Long id,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
boardService.setNotice(id, true, current);
return ResponseEntity.noContent().build();
}
/** 글 작성 시 선택 가능한 태그 (게시판 설정 카테고리로 제한) */
/** 공지 해제 (관리자) */
@PostMapping("/posts/{id}/unnotice")
public ResponseEntity<Void> unnotice(
@PathVariable Long id,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
boardService.setNotice(id, false, current);
return ResponseEntity.noContent().build();
}
/** 목록 태그 필터 — 게시판 지정 시 그 게시판에 매핑된 그룹의 태그만 */
@GetMapping("/tags")
public List<String> tags(@RequestParam(required = false) String category) {
if (category == null || category.isBlank()) {
return boardService.allTags();
}
return tagService.listWritableGroups(category).stream()
.flatMap(g -> g.getTags().stream())
.map(com.sb.web.board.dto.TagResponse::getName)
.distinct()
.toList();
}
/** 글 작성 시 선택 가능한 태그 (해당 게시판에 매핑된 그룹만) */
@GetMapping("/tag-groups")
public List<TagCategoryResponse> tagGroups() {
return tagService.listWritableGroups();
public List<TagCategoryResponse> tagGroups(@RequestParam(required = false) String category) {
return tagService.listWritableGroups(category);
}
@PostMapping("/posts/{id}/comments")
@@ -125,4 +172,55 @@ public class BoardController {
boardService.deleteComment(commentId, current);
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 authorId;
private String authorName;
private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함)
private String authorProfileImage; // 조회 시 member JOIN (저장 안 함)
private String content;
private Boolean blocked; // 신고 누적 블라인드
private LocalDateTime createdAt;
}
@@ -22,9 +22,12 @@ public class Post implements Serializable {
private String content;
private Long authorId;
private String authorName;
private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함)
private String authorProfileImage; // 조회 시 member JOIN (저장 안 함)
private Integer viewCount;
private Boolean blocked;
private String blockReason;
private Boolean notice; // 공지(상단 고정) — 관리자만 설정
private String category; // 게시판 구분: community/saving/tips
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -1,11 +0,0 @@
package com.sb.web.board.dto;
import lombok.Data;
/**
* 게시판 설정 변경 요청 — 사용할 태그 카테고리(null 이면 제한 없음).
*/
@Data
public class BoardSettingRequest {
private Long tagCategoryId;
}
@@ -1,13 +0,0 @@
package com.sb.web.board.dto;
import lombok.Builder;
import lombok.Data;
/**
* 게시판 설정 응답.
*/
@Data
@Builder
public class BoardSettingResponse {
private Long tagCategoryId;
}
@@ -16,16 +16,28 @@ public class CommentResponse {
private Long id;
private Long authorId;
private String authorName;
private String authorGooglePicture;
private String authorProfileImage;
private String content;
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) {
return CommentResponse.builder()
.id(c.getId())
.authorId(c.getAuthorId())
.authorName(c.getAuthorName())
.authorGooglePicture(c.getAuthorGooglePicture())
.authorProfileImage(c.getAuthorProfileImage())
.content(c.getContent())
.blocked(Boolean.TRUE.equals(c.getBlocked()))
.createdAt(c.getCreatedAt())
.upCount(0)
.downCount(0)
.build();
}
}
@@ -0,0 +1,15 @@
package com.sb.web.board.dto;
import lombok.Data;
/**
* 한 게시글의 댓글별 추천/비추천 집계 + 현재 사용자 투표 (N+1 방지용 일괄 조회 결과).
*/
@Data
public class CommentVoteSummary {
private Long commentId;
private int upCount;
private int downCount;
private String myVote; // UP / DOWN / null
}
@@ -0,0 +1,19 @@
package com.sb.web.board.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 내 댓글 모아보기 항목 (어느 글의 댓글인지 함께).
*/
@Data
public class MyCommentResponse {
private Long id;
private String content;
private LocalDateTime createdAt;
private Long postId;
private String postTitle;
private String category;
}
@@ -19,14 +19,21 @@ public class PostDetail {
private String content;
private Long authorId;
private String authorName;
private String authorGooglePicture;
private String authorProfileImage;
private Integer viewCount;
private Boolean blocked;
private String blockReason;
private Boolean notice;
private String category;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<String> tags;
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) {
return PostDetail.builder()
@@ -35,9 +42,12 @@ public class PostDetail {
.content(p.getContent())
.authorId(p.getAuthorId())
.authorName(p.getAuthorName())
.authorGooglePicture(p.getAuthorGooglePicture())
.authorProfileImage(p.getAuthorProfileImage())
.viewCount(p.getViewCount())
.blocked(Boolean.TRUE.equals(p.getBlocked()))
.blockReason(p.getBlockReason())
.notice(Boolean.TRUE.equals(p.getNotice()))
.category(p.getCategory())
.createdAt(p.getCreatedAt())
.updatedAt(p.getUpdatedAt())
@@ -54,6 +64,7 @@ public class PostDetail {
.authorName(p.getAuthorName())
.viewCount(p.getViewCount())
.blocked(true)
.notice(Boolean.TRUE.equals(p.getNotice()))
.category(p.getCategory())
.createdAt(p.getCreatedAt())
.tags(List.of())
@@ -22,6 +22,9 @@ public class PostRequest {
/** 게시판 구분: community/saving/tips. 없으면 community */
private String category;
/** 공지 등록 여부 (관리자만 반영). 작성 시 선택 */
private Boolean notice;
/** 선택한 태그 ID 목록 (DB에 등록된 태그만, 선택) */
private List<Long> tagIds;
}
@@ -13,10 +13,18 @@ public class PostSummary {
private Long id;
private String title;
private String category; // 내 글 모아보기 등 교차 게시판 링크용
private Long authorId;
private String authorName;
private String authorGooglePicture;
private String authorProfileImage;
private Integer viewCount;
private Integer commentCount;
private Integer upCount; // 추천 수
private Integer downCount; // 비추천 수
private Boolean hot; // 인기 글(추천 50+ & 1일 이내) 상단 노출
private Boolean blocked;
private Boolean notice;
private LocalDateTime createdAt;
private List<String> tags;
}
@@ -0,0 +1,15 @@
package com.sb.web.board.dto;
import lombok.Data;
/**
* 추천(👍)한 사람 (본문 하단 아바타·이름 표기용).
*/
@Data
public class Recommender {
private Long memberId;
private String name;
private String googlePicture;
private String profileImage;
}
@@ -4,6 +4,8 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 태그 카테고리 생성/수정 요청.
*/
@@ -15,4 +17,7 @@ public class TagCategoryRequest {
private String name;
private Integer sortOrder;
/** 이 그룹을 사용할 게시판 (community/saving/tips). 비어있으면 전체 게시판. */
private List<String> boards;
}
@@ -17,13 +17,15 @@ public class TagCategoryResponse {
private String name;
private Integer sortOrder;
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()
.id(c.getId())
.name(c.getName())
.sortOrder(c.getSortOrder())
.tags(tags)
.boards(boards)
.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 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("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);
int insert(Post post);
@@ -35,4 +47,7 @@ public interface PostMapper {
int updateBlocked(@Param("id") Long id,
@Param("blocked") boolean blocked,
@Param("blockReason") String blockReason);
int updateNotice(@Param("id") Long id,
@Param("notice") boolean notice);
}
@@ -0,0 +1,23 @@
package com.sb.web.board.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 신고(report) 매퍼 — 글/댓글 신고 누적 집계.
*/
@Mapper
public interface ReportMapper {
/** 신고 추가 (회원당 대상별 1회 — 중복은 무시) */
int insertIgnore(@Param("targetType") String targetType,
@Param("targetId") Long targetId,
@Param("memberId") Long memberId,
@Param("reason") String reason);
int countByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
int deleteByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
int deleteByMember(@Param("memberId") Long memberId);
}
@@ -0,0 +1,20 @@
package com.sb.web.board.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 태그 카테고리(그룹) ↔ 게시판 매핑 (tag_category_board).
*/
@Mapper
public interface TagCategoryBoardMapper {
/** 카테고리가 노출될 게시판 목록 */
List<String> findBoards(@Param("categoryId") Long categoryId);
void deleteByCategory(@Param("categoryId") Long categoryId);
void insert(@Param("categoryId") Long categoryId, @Param("board") String board);
}
@@ -0,0 +1,48 @@
package com.sb.web.board.mapper;
import com.sb.web.board.dto.CommentVoteSummary;
import com.sb.web.board.dto.Recommender;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 게시글/댓글 추천·비추천(post_vote / comment_vote) 매퍼.
*/
@Mapper
public interface VoteMapper {
// ===== 게시글 =====
/** 현재 사용자의 게시글 투표(UP/DOWN) 또는 null */
String findPostVote(@Param("postId") Long postId, @Param("memberId") Long memberId);
/** 투표 등록/변경 (INSERT … ON DUPLICATE KEY UPDATE) */
int upsertPostVote(@Param("postId") Long postId, @Param("memberId") Long memberId, @Param("type") String type);
int deletePostVote(@Param("postId") Long postId, @Param("memberId") Long memberId);
int countPostVotes(@Param("postId") Long postId, @Param("type") String type);
/** 추천(👍)한 사람 목록 (최신순) */
List<Recommender> findPostRecommenders(@Param("postId") Long postId);
int deletePostVotesByPost(@Param("postId") Long postId);
// ===== 댓글 =====
String findCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId);
int upsertCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId, @Param("type") String type);
int deleteCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId);
int countCommentVotes(@Param("commentId") Long commentId, @Param("type") String type);
/** 한 게시글의 댓글별 집계 + 현재 사용자 투표 (일괄) */
List<CommentVoteSummary> findCommentVoteSummary(@Param("postId") Long postId, @Param("memberId") Long memberId);
int deleteCommentVotesByComment(@Param("commentId") Long commentId);
/** 게시글 삭제 시 그 글의 모든 댓글 투표 정리 */
int deleteCommentVotesByPost(@Param("postId") Long postId);
}
@@ -5,14 +5,20 @@ import com.sb.web.board.domain.Comment;
import com.sb.web.board.domain.Post;
import com.sb.web.board.dto.CommentRequest;
import com.sb.web.board.dto.CommentResponse;
import com.sb.web.board.dto.CommentVoteSummary;
import com.sb.web.board.dto.PageResponse;
import com.sb.web.board.dto.PostDetail;
import com.sb.web.board.dto.PostRequest;
import com.sb.web.board.dto.PostSummary;
import com.sb.web.board.dto.Recommender;
import com.sb.web.board.dto.VoteResponse;
import com.sb.web.board.mapper.CommentMapper;
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.VoteMapper;
import com.sb.web.common.exception.ApiException;
import com.sb.web.point.PointService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
@@ -20,7 +26,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
@@ -33,10 +41,24 @@ public class BoardService {
private final PostMapper postMapper;
private final TagMapper tagMapper;
private final CommentMapper commentMapper;
private final VoteMapper voteMapper;
private final ReportMapper reportMapper;
private final PointService pointService;
private final RedisTemplate<String, Object> redisTemplate;
/** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */
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);
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);
}
/** 내 글 모아보기 */
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) {
return ("content".equals(t) || "comment".equals(t)) ? t : "title";
}
@@ -80,7 +132,7 @@ public class BoardService {
postMapper.increaseViewCount(id);
post.setViewCount(post.getViewCount() + 1);
}
return assemble(post);
return assemble(post, viewer);
}
/** 게시글 열람 제한/해제 (관리자 전용) */
@@ -89,6 +141,30 @@ public class BoardService {
assertAdmin(user);
mustFindPost(id);
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);
}
/** 게시글 공지 설정/해제 (관리자 전용) — 목록 최상단 고정 */
@Transactional
public void setNotice(Long id, boolean notice, SessionUser user) {
assertAdmin(user);
mustFindPost(id);
postMapper.updateNotice(id, notice);
}
/** Redis 로 (사용자, 게시글) 단위 첫 열람 여부 판단 → 중복 클릭 시 조회수 증가 방지 */
@@ -108,11 +184,14 @@ public class BoardService {
.content(req.getContent())
.authorId(author.getId())
.authorName(author.getName())
// 공지는 관리자만 설정 가능
.notice(isAdmin(author) && Boolean.TRUE.equals(req.getNotice()))
.category(toCategory(req.getCategory()))
.build();
postMapper.insert(post);
applyTags(post.getId(), req.getTagIds());
return assemble(mustFindPost(post.getId()));
pointService.awardBoardWrite(author.getId()); // 글 작성 보상(일 3회 한도)
return assemble(mustFindPost(post.getId()), author);
}
@Transactional
@@ -127,15 +206,24 @@ public class BoardService {
post.setContent(req.getContent());
postMapper.update(post);
// 공지는 관리자만 변경 (수정 화면 체크박스 반영)
if (isAdmin(user)) {
postMapper.updateNotice(id, Boolean.TRUE.equals(req.getNotice()));
}
tagMapper.deletePostTagsByPostId(id);
applyTags(id, req.getTagIds());
return assemble(mustFindPost(id));
return assemble(mustFindPost(id), user);
}
@Transactional
public void delete(Long id, SessionUser user) {
Post post = mustFindPost(id);
assertCanModify(post.getAuthorId(), user);
// 투표 정리(댓글 투표는 댓글 삭제 전에 — 서브쿼리가 comment 참조)
voteMapper.deleteCommentVotesByPost(id);
voteMapper.deletePostVotesByPost(id);
reportMapper.deleteByTarget(T_POST, id);
tagMapper.deletePostTagsByPostId(id);
commentMapper.deleteByPostId(id);
postMapper.delete(id);
@@ -156,6 +244,7 @@ public class BoardService {
.content(req.getContent())
.build();
commentMapper.insert(comment);
pointService.awardBoardWrite(author.getId()); // 댓글 작성 보상(일 3회 한도, 글과 합산)
return CommentResponse.from(commentMapper.findById(comment.getId()));
}
@@ -166,9 +255,97 @@ public class BoardService {
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
}
assertCanModify(comment.getAuthorId(), user);
voteMapper.deleteCommentVotesByComment(commentId);
reportMapper.deleteByTarget(T_COMMENT, 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() {
@@ -177,11 +354,62 @@ public class BoardService {
/* ===================== 내부 ===================== */
private PostDetail assemble(Post post) {
List<String> tags = tagMapper.findNamesByPostId(post.getId());
List<CommentResponse> comments = commentMapper.findByPostId(post.getId())
.stream().map(CommentResponse::from).toList();
return PostDetail.of(post, tags, comments);
private PostDetail assemble(Post post, SessionUser viewer) {
Long postId = post.getId();
Long viewerId = viewer == null ? null : viewer.getId();
List<String> tags = tagMapper.findNamesByPostId(postId);
// 댓글별 추천/비추천 집계를 한 번에 조회(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에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */
@@ -4,10 +4,9 @@ import com.sb.web.board.domain.Tag;
import com.sb.web.board.domain.TagCategory;
import com.sb.web.board.dto.TagCategoryRequest;
import com.sb.web.board.dto.TagCategoryResponse;
import com.sb.web.board.dto.BoardSettingResponse;
import com.sb.web.board.dto.TagRequest;
import com.sb.web.board.dto.TagResponse;
import com.sb.web.board.mapper.BoardSettingMapper;
import com.sb.web.board.mapper.TagCategoryBoardMapper;
import com.sb.web.board.mapper.TagCategoryMapper;
import com.sb.web.board.mapper.TagMapper;
import com.sb.web.common.exception.ApiException;
@@ -18,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 태그 카테고리 + 태그 관리 서비스 (관리자 기능 + 그룹 조회).
@@ -28,7 +28,10 @@ public class TagService {
private final TagCategoryMapper categoryMapper;
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() {
@@ -39,37 +42,43 @@ public class TagService {
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();
}
TagCategory c = categoryMapper.findById(catId);
return c == null ? listGrouped() : List.of(toGroup(c));
List<TagCategoryResponse> result = new ArrayList<>();
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) {
List<TagResponse> tags = tagMapper.findByCategoryId(c.getId())
.stream().map(TagResponse::from).toList();
return TagCategoryResponse.of(c, tags);
List<String> boards = categoryBoardMapper.findBoards(c.getId());
return TagCategoryResponse.of(c, tags, boards);
}
/* ===================== 게시판 설정 ===================== */
public BoardSettingResponse getBoardSetting() {
return BoardSettingResponse.builder()
.tagCategoryId(boardSettingMapper.findTagCategoryId())
.build();
/** 카테고리의 게시판 매핑을 교체 저장 (유효 게시판 값만, 중복 제거) */
private void saveBoards(Long categoryId, List<String> boards) {
categoryBoardMapper.deleteByCategory(categoryId);
if (boards == null) {
return;
}
@Transactional
public BoardSettingResponse setBoardSetting(Long tagCategoryId) {
if (tagCategoryId != null && categoryMapper.findById(tagCategoryId) == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
}
boardSettingMapper.updateTagCategoryId(tagCategoryId);
return BoardSettingResponse.builder().tagCategoryId(tagCategoryId).build();
boards.stream()
.filter(b -> b != null && VALID_BOARDS.contains(b))
.distinct()
.forEach(b -> categoryBoardMapper.insert(categoryId, b));
}
/* ===================== 카테고리 ===================== */
@@ -85,7 +94,8 @@ public class TagService {
.sortOrder(req.getSortOrder() != null ? req.getSortOrder() : 0)
.build();
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
@@ -100,11 +110,12 @@ public class TagService {
c.setSortOrder(req.getSortOrder());
}
categoryMapper.update(c);
saveBoards(id, req.getBoards());
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
public void deleteCategory(Long id) {
mustFindCategory(id);
@@ -112,6 +123,7 @@ public class TagService {
tagMapper.deletePostTagsByTagId(t.getId());
tagMapper.delete(t.getId());
}
categoryBoardMapper.deleteByCategory(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.AuthInterceptor;
import com.sb.web.auth.web.PremiumInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
@@ -18,16 +19,33 @@ public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final AdminInterceptor adminInterceptor;
private final PremiumInterceptor premiumInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/auth/password",
"/api/auth/verify-password", "/api/auth/profile",
"/api/board/**", "/api/admin/**", "/api/account/**");
.addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history",
"/api/auth/logout", "/api/auth/password",
"/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 검사)
registry.addInterceptor(adminInterceptor)
.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);
}
+2
View File
@@ -76,6 +76,8 @@ app:
account-key: ${ACCOUNT_CRYPTO_KEY:}
# 업로드 이미지 등 외부에서 접근할 절대 URL 베이스. 앱(Capacitor)은 출처가 localhost 라 상대경로가 안 통함.
public-url: ${PUBLIC_URL:https://app.sblog.kr}
# 구글 OAuth 웹 클라이언트 ID (Google Cloud Console 발급). 미설정 시 구글 로그인 비활성.
google-client-id: ${GOOGLE_CLIENT_ID:}
# ===== Logging =====
logging:
+50
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 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 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 정의에 반영).
-- 매 기동 MODIFY 는 라이브 테이블 락 위험이 있어 제거. 기존 환경 확장이 필요하면 1회 수동 적용:
-- 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 notif_key VARCHAR(160) NULL COMMENT '알림 자동인식 중복방지 키';
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 (
@@ -104,12 +113,44 @@ CREATE TABLE IF NOT EXISTS account_category (
member_id BIGINT NOT NULL COMMENT '소유자 member.id',
type VARCHAR(10) NOT NULL COMMENT 'INCOME / EXPENSE',
name VARCHAR(50) NOT NULL,
parent_id BIGINT NULL COMMENT '대분류 id(account_category.id). NULL=대분류, 값=소분류',
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_category_member_type_name (member_id, type, name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 대/소분류 계층 추가(기존 DB 마이그레이션) — 소분류 이름은 그대로, parent_id 만 부여
ALTER TABLE account_category ADD COLUMN IF NOT EXISTS parent_id BIGINT NULL COMMENT '대분류 id. NULL=대분류, 값=소분류';
-- 자주 쓰는 내역(빠른 등록 템플릿) — 사용자별. 칩 탭 시 오늘 날짜로 즉시 등록.
CREATE TABLE IF NOT EXISTS quick_entry (
id BIGINT NOT NULL AUTO_INCREMENT,
member_id BIGINT NOT NULL COMMENT '소유자 member.id',
label VARCHAR(50) NULL COMMENT '칩 표시 이름(없으면 분류/메모 사용)',
type VARCHAR(10) NOT NULL COMMENT 'INCOME / EXPENSE',
category VARCHAR(50) NULL,
amount BIGINT NOT NULL,
memo VARCHAR(255) NULL,
wallet_id BIGINT NULL COMMENT '출금/입금 계좌(account_wallet 등) id',
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_quick_entry_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 기본(디폴트) 분류 — 전체 사용자 공용 템플릿(관리자 관리). 사용자가 '기본 분류 불러오기'로 자기 분류에 복사.
CREATE TABLE IF NOT EXISTS default_category (
id BIGINT NOT NULL AUTO_INCREMENT,
type VARCHAR(10) NOT NULL COMMENT 'INCOME / EXPENSE',
name VARCHAR(50) NOT NULL,
parent_id BIGINT NULL COMMENT '대분류 id(default_category.id). NULL=대분류, 값=소분류',
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_default_category_type_name (type, name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 예산 (사용자별, 카테고리별)
-- fixed=1(고정): base_unit/base_amount 로 일수기준 환산 / fixed=0(비고정): daily/weekly/monthly/yearly 직접
CREATE TABLE IF NOT EXISTS budget (
@@ -129,6 +170,15 @@ CREATE TABLE IF NOT EXISTS budget (
UNIQUE KEY uk_budget_member_category (member_id, category)
) 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 참조)
CREATE TABLE IF NOT EXISTS account_entry_tag (
entry_id BIGINT NOT NULL,
+47 -10
View File
@@ -28,6 +28,8 @@ ALTER TABLE post ADD COLUMN IF NOT EXISTS block_reason VARCHAR(255) NULL;
ALTER TABLE post ADD COLUMN IF NOT EXISTS category VARCHAR(30) NOT NULL DEFAULT 'community';
-- 큰 이미지(base64 인라인) 수용을 위해 content 를 LONGTEXT 로 확대 (멱등)
ALTER TABLE post MODIFY content LONGTEXT NOT NULL;
-- 공지(상단 고정) — 관리자만 설정. 목록에서 최상단 노출 (멱등)
ALTER TABLE post ADD COLUMN IF NOT EXISTS notice TINYINT(1) NOT NULL DEFAULT 0 COMMENT '공지(상단 고정)';
-- 태그 카테고리(그룹) — 예: 게시판, 게시판2 ...
CREATE TABLE IF NOT EXISTS tag_category (
@@ -52,6 +54,15 @@ CREATE TABLE IF NOT EXISTS tag (
-- 기존 tag 테이블에 category_id 가 없으면 추가 (멱등)
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 (
post_id BIGINT NOT NULL,
@@ -60,16 +71,6 @@ CREATE TABLE IF NOT EXISTS post_tag (
KEY idx_post_tag_tag (tag_id)
) 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 미사용).
CREATE TABLE IF NOT EXISTS board_image (
id BIGINT NOT NULL AUTO_INCREMENT,
@@ -93,3 +94,39 @@ CREATE TABLE IF NOT EXISTS comment (
PRIMARY KEY (id),
KEY idx_comment_post (post_id)
) 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;

Some files were not shown because too many files have changed in this diff Show More