Compare commits

...

50 Commits

Author SHA1 Message Date
sb b6976f5612 fix(auth): 애플 로그인 aud 기본값 application.yml → kr.sblog.slimbudget.app
Deploy / deploy (push) Failing after 11m4s
- @Value 기본값보다 application.yml 이 우선 → 실제 값이 옛 번들ID였음
- iOS 번들ID 변경(.app)에 맞춰 서버 기대 aud 정합 (유효하지 않은 애플 토큰 해결)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:28:42 +09:00
sb 4b23632314 chore(auth): 애플 로그인 aud 기본값 = iOS 번들ID kr.sblog.slimbudget.app
Deploy / deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:43:31 +09:00
ByungCheol 6db6cb055e feat(shop): 포인트 상점 구현 — 계좌 슬롯(500P)·AI 통계 크레딧(1000P→10회)
Deploy / deploy (push) Successful in 56s
- member 테이블: extra_wallet_slots, ai_stat_credits 컬럼 추가(ALTER IF NOT EXISTS)
- MemberMapper: 상점 관련 5개 메서드 추가(getExtraWalletSlots/getAiStatCredits/incrementExtraWalletSlots/addAiStatCredits/decrementAiStatCredit)
- ShopService: buyWalletSlot(500P, max 3개), buyAiStatPack(1000P→10회) 구현
- ShopController: GET /api/account/shop/status, POST /api/account/shop/buy
- AccountService.freeWalletLimit: bonus=0 TODO → memberMapper.getExtraWalletSlots 실제 조회
- AccountService.aiComment: FREE 회원 크레딧 차감 로직 추가, 소진 시 402 반환
- AccountController.aiComment: current 파라미터 전달
- WebConfig: /api/account/ai-comment 를 premiumInterceptor 에서 제거(서비스 레이어에서 처리)
- BackupController: @DeleteMapping import 누락 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 00:32:30 +09:00
ByungCheol 75d5c9fa9b feat(point): 월별 포인트 내역 조회 API — year/month 파라미터 추가
Deploy / deploy (push) Failing after 14m43s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 23:46:24 +09:00
ByungCheol 22c492c1d2 fix(backup): budget_onetime 삭제 누락 추가 — deleteAll·restore 양쪽 모두 적용
Deploy / deploy (push) Failing after 11m5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 23:10:03 +09:00
ByungCheol 795afc80ff feat(account): 가계부 데이터 전체 삭제 API (DELETE /account/all)
Deploy / deploy (push) Failing after 12m1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 22:39:06 +09:00
sb ee644515cb fix(wallet): updateWallet 에서 loanPayment 저장 누락 수정
Deploy / deploy (push) Failing after 14m21s
- 기존 대출 수정 시 월 상환금(loanPayment)이 저장되지 않던 문제
  (create 는 toWallet 로 반영됐으나 update 는 개별 setter 에서 누락)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:12:10 +09:00
sb ee2bc08b1e feat(wallet): 대출 월 상환금(loan_payment) 필드 추가
Deploy / deploy (push) Failing after 13m41s
- 원리금균등 상환표를 은행 조회액과 정확히 맞추기 위한 고정 월 상환금 선택 저장
- wallet.loan_payment 컬럼(ALTER IF NOT EXISTS), 도메인·DTO·매퍼 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:07:49 +09:00
ByungCheol 646d5f53fa fix(ai): 교통 분류 절약 팁 제외 — 정기권 최저비용 사용 중
Deploy / deploy (push) Failing after 13m27s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:02:51 +09:00
sb 0780546d4f feat(recurring): 고정지출 무료 2건 허용(써보고 업셀) — 유료전용 해제
Deploy / deploy (push) Failing after 13m57s
WebConfig에서 /recurrings 유료 게이트 제거, 무료는 생성 시 종류무관 2건 한도(초과 400).
유료·관리자 무제한, 복원은 한도 미적용. RecurringMapper.countByMember 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:22:31 +09:00
sb 489c3aacb7 fix(ai): 빠른입력 금액 — 한글 수(오만원·삼만원·십만원 등) 해석 예시 추가
Deploy / deploy (push) Failing after 12m54s
아라비아 숫자만 예시라 '오만원' 등 한글 표기 금액을 놓치던 문제. 한글 수 예시 보강.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:28:36 +09:00
sb 4132a17c6a feat(ai): 빠른입력 이체(TRANSFER) 지원 — 출금/입금 계좌 분리 추출
Deploy / deploy (push) Failing after 14m51s
스키마에 TRANSFER 타입 + toWallet(받는 계좌) 추가. 이체 시 wallet=출금·toWallet=입금 계좌를
후보 중에서 매칭, 분류는 비움. 기존엔 이체가 EXPENSE로 뭉개지던 문제 해결.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:16:38 +09:00
sb 29b14a5464 feat(wallet): 무료 회원 계좌 종류별 2개 한도 (유료 무제한)
Deploy / deploy (push) Failing after 11m4s
- createWallet 에 premium 여부 전달, 무료는 종류별 countByType >= 한도면 400
- 한도 계산을 freeWalletLimit 한 곳에 모음(추후 포인트 상점의 영구 보너스 슬롯 최대 5까지 확장 지점)
- 복원(BackupService)은 본인 데이터 복구라 한도 우회

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 01:40:25 +09:00
sb cc36f9e939 revert(point): 개근 보너스 전월 제한 복구 — 현재 월만 인정
Deploy / deploy (push) Failing after 13m56s
테스트(6월 검증) 완료 후 원래 규칙으로 복구. 전월 소급 파밍 방지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:07:35 +09:00
sb 56197c2689 test(point): 개근 보너스 전월 제한 임시 해제 — 6월 데이터 검증용
Deploy / deploy (push) Failing after 15m31s
테스트 후 되돌릴 예정(현재 월만 인정으로 복구).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:01:00 +09:00
sb 45bb025247 feat(point): 한 달 개근(모든 날 기록) 랜덤 보너스 1~100P
Deploy / deploy (push) Failing after 14m42s
내역 저장 시 현재 월 모든 날에 기록이 있으면 월 1회 랜덤 1~100P 지급.
전월 소급 입력 파밍 방지(현재 월만 인정), 화면 안내 없음(포인트 내역에만).
- AccountEntryMapper.countDistinctEntryDays, PointMapper.countByReason
- PointService.awardMonthComplete(reason=MONTH_COMPLETE_yyyyMM 로 월 1회)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:56:48 +09:00
ByungCheol a0509099f7 feat(budget): 1회성 예산 API 추가
Deploy / deploy (push) Failing after 11m13s
- BudgetOnetime 도메인/DTO/Mapper/Service 신규
- BudgetController: GET/POST/PUT/DELETE /api/account/budgets/onetime 엔드포인트 추가
- account.sql: budget_onetime 테이블 CREATE TABLE IF NOT EXISTS 추가
- WalletRequest: MINUS 유형 허용 추가
- CategoryService: importDefaults, validateParent, ensureCategory 추가

※ 최초 배포 시 서버 .env에 SQL_INIT_MODE=always 추가 후 재시작 필요 (budget_onetime 테이블 생성)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 16:15:15 +09:00
sb 24858f23e8 style(ai): 재무 코멘트·절약가이드 말투를 정중한 종결어미로 통일
Deploy / deploy (push) Failing after 14m37s
'~입니다/~합니다/~할 수 있습니다' 합쇼체 강제, 반말·음슴체 금지 + 예시 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:45:25 +09:00
sb 7d15f06390 revert(ai): 예상 지출을 AI에서 제거 — 비결정성으로 매 접속 값 변동
Deploy / deploy (push) Failing after 12m44s
AI 예상지출은 호출마다 40만~500만으로 흔들려 신뢰성 부족. 코멘트/팁만 AI 유지.
예상 지출은 프론트에서 결정적 계산으로 대체.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:43:44 +09:00
sb 2802ec9d5a feat(ai): 월말 예상 지출을 AI 추정으로 — 일회성 큰 지출 왜곡 제거
Deploy / deploy (push) Failing after 12m37s
선형 run-rate는 노트북 등 일회성 지출을 남은 날에 곱해 비현실적 값(2천만) 생성.
AI가 분류를 보고 일회성을 일평균에서 제외해 추정 + 근거 문장 반환(경과일/총일수 전달).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:28:53 +09:00
sb a34a8250a4 feat(ai): 재무 코멘트에 절약 가이드(tips) 추가
Deploy / deploy (push) Failing after 13m20s
집계 기반으로 현황 코멘트(bullets)에 더해 지출 패턴 맞춤 절약 제안(tips) 2~3개 생성.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:13:09 +09:00
sb 4aeac2ac0f feat(ai): 통계 화면 AI 재무 코멘트
Deploy / deploy (push) Failing after 10m59s
이번 달 집계(수입·지출·전월대비·예산·분류별)만 Claude Haiku에 보내 2~3개 코멘트 생성.
POST /account/ai-comment(유료 게이트), 거래 원문 미전송. 미설정/실패 시 빈 코멘트→카드 숨김.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:05:32 +09:00
ByungCheol 655af7af63 feat(fx): 환율 캐시 시각 노출 — updatedAt 응답 추가
Deploy / deploy (push) Failing after 15m14s
- FxService: Cached 레코드에 fetchedAt(Instant) 추가, getCachedAt() 공개
- FxController: GET /api/fx/rate 응답에 updatedAt(ISO 문자열) 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 14:51:14 +09:00
sb c5f16795ef fix(ai): 영수증 AI 멀티파트 파트수 초과(500) 수정
Deploy / deploy (push) Failing after 15m18s
Tomcat 기본 파트 수 제한(10)에 categoryHints/walletHints를 이름마다 파트로 보내 초과 → 500.
힌트를 줄바꿈으로 합친 단일 필드로 받아 파트 3개로 축소.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:46:14 +09:00
sb 28670b69a1 feat(ai): 영수증 사진 → Claude Vision 구조화
Deploy / deploy (push) Successful in 57s
/account/ocr/receipt-ai — 이미지를 Haiku 비전에 보내 금액·상호·날짜·분류·계좌를 한 번에 추출.
AI 미설정/실패 시 recognized=false → 프론트가 기존 Google Vision OCR 로 폴백.
Claude 호출/응답파싱 공통화(callClaude), 프롬프트 힌트주입 공통화(withHints).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:18:50 +09:00
sb 53326f089b feat(ai): 자연어 입력 시 분류·결제수단 자동 추천
Deploy / deploy (push) Failing after 12m30s
사용자의 실제 카테고리·계좌 후보를 AI에 전달해 그 중에서 선택(할루시네이션 방지 검증).
라면→식비, 삼성카드→해당 계좌 등 폼 자동 채움 확장.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:47:52 +09:00
ByungCheol 82eea0b908 feat(board): 스팸 키워드 자동 블라인드 및 관리자 신고 목록 노출
Deploy / deploy (push) Failing after 14m15s
- BoardService: create/addComment에서 isSpam 감지 시 자동 블라인드 처리, 포인트 미지급
- ReportMapper: listReported 쿼리 확장 — 신고 누적 OR 스팸 감지 블라인드 항목 모두 포함
- ReportedItem: blockReason 필드 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 13:47:13 +09:00
sb b3b42f0db6 fix(ai): 모델이 설명문 섞어 반환해도 JSON만 추출 — 파싱 견고성
Deploy / deploy (push) Failing after 13m39s
첫 '{'~마지막 '}' 구간만 추출해 파싱, 중괄호 없으면 조용히 규칙 폴백(WARN 대신 INFO)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:57:50 +09:00
ByungCheol 32da57e0e0 feat(point): 게시판 포인트 정책 개편
Deploy / deploy (push) Failing after 12m48s
작성: 10P, 일 50P 한도(5회), 스팸 키워드 포함 시 미지급
추천 받음(작성자): +5P, 일 50P 한도
비추천 받음(작성자): -1P
남의 글 추천(투표자): +1P, 일 10P 한도
추천 취소(투표자): -1P
addPoints에 GREATEST(0,...) 적용 — 잔액 음수 방지
2026-07-05 12:38:40 +09:00
sb d65dd88bba chore(ai): 파싱 전과정 진단 로그(요청텍스트·모델원문·결과) 추가
Deploy / deploy (push) Failing after 14m50s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:36:40 +09:00
sb 30cb68c003 chore(ai): 시작 시 AI 파서 활성/키로딩 진단 로그 추가
Deploy / deploy (push) Failing after 14m25s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:12:04 +09:00
sb 811cc1b391 feat(ai): 자연어 한 줄 입력 파싱 — Claude Haiku 프록시(키 서버보관·실패 시 규칙 폴백)
Deploy / deploy (push) Failing after 13m20s
- AiEntryParser: /v1/messages(Haiku) 호출로 '어제 스타벅스 5천원'→{type,amount,merchant,date} 추출
- AccountService.parseText: AI 우선 시도, 미설정/실패/미인식 시 기존 규칙기반 파서로 폴백(무중단)
- app.anthropic-api-key/anthropic-model 설정(키 없으면 AI 비활성)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:24:14 +09:00
sb 0082e647c2 ci: setup-java 캐시 제거 — 'Save cache' 후처리 hang 회피
Deploy / deploy (push) Successful in 49s
Gitea 캐시 서버 미연결 시 cache:gradle 저장 후처리가 수 분 hang → 가짜 실패 유발.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:56:10 +09:00
sb 9b5abedd40 ci(deploy): 배포 잡에서 테스트/서비스 제거 — bootJar 만 빌드
Deploy / deploy (push) Has been cancelled
서비스 컨테이너(MariaDB/Redis) 후처리 cleanup 이 러너에서 멈춰 15분 타임아웃 →
'가짜 실패'로 표시되던 문제 회피. 배포 잡은 clean bootJar 로 jar 만 만들어 ship.
테스트 게이트는 ci.yaml(같은 push 에서 실행)이 담당.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:48:20 +09:00
sb 9fdec36758 fix(ci): SQL_INIT_MODE=always 추가 — 컨텍스트 테스트 스키마 초기화
Deploy / deploy (push) Failing after 15m48s
@SpringBootTest(contextLoads) 시작 시 DefaultCategorySeeder 가 테이블을 조회하는데
CI 는 SQL_INIT_MODE 미설정(never)이라 스키마가 없어 BadSqlGrammarException 으로 실패했음.
운영과 동일하게 always 로 초기화 → 애플 로그인 커밋(a10e16c) 배포도 함께 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 00:00:43 +09:00
ByungCheol 1d90f065e5 feat(point): 가계부 내역 작성 포인트 — 건당 10p, 일 50p 한도
Deploy / deploy (push) Failing after 11m31s
AccountService.create() 호출 시 PointService.awardAccountEntry() 적립.
ACCOUNT_ENTRY reason, 일 5회(50p) 초과 시 미지급.
2026-07-04 23:14:46 +09:00
sb a10e16c1af feat(auth): 애플 로그인 API — Sign in with Apple identity token 검증(JWKS)
Deploy / deploy (push) Failing after 12m48s
- POST /api/auth/apple — nimbus-jose-jwt 로 애플 공개키(JWKS) 서명 검증 + iss/aud(번들ID)/exp 확인
- member.apple_id 컬럼(멱등 ALTER) + findByAppleId/linkApple, 구글과 동일한 이메일 자동연결 로직
- app.apple-client-id 설정(기본 kr.sblog.slimbudget)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:01:31 +09:00
sb 2f376e6f77 feat(board): 신고 목록 조회 API + 신고 무시(관리자)
Deploy / deploy (push) Failing after 11m22s
- GET /api/board/reports — 글/댓글 신고 대상별 집계(UNION, 신고 많은 순)
- POST /api/board/reports/dismiss — 블라인드 없이 신고 기록만 초기화
- 관리자 신고 관리 화면 백엔드

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:11:58 +09:00
sb 11d1655261 docs: BACKEND.md 현행화(신규 API·스키마·투자 모델·구글로그인)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:11:58 +09:00
ByungCheol 8ced77480e fix(cors): localhost:5174 허용 추가 — Vite 포트 충돌 시 자동 증가 대응
Deploy / deploy (push) Failing after 11m11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:45:14 +09:00
ByungCheol 9ed80f7085 fix(config): 로컬 기동 시 SQL 초기화 스킵(never) — 운영은 SQL_INIT_MODE=always 환경변수로 제어
Deploy / deploy (push) Failing after 13m10s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:07:24 +09:00
ByungCheol 9b04ad3310 fix(logging): 로그 파일 경로를 상대경로(logs/)로 변경 — 절대경로 디렉토리 미존재 문제 해결
Deploy / deploy (push) Failing after 12m18s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:49:02 +09:00
ByungCheol f62889ac62 chore: 일별 롤링 로그 추가 (/opt/sb-backend/logs/app.YYYY-MM-DD.log, 30일 보관)
Deploy / deploy (push) Failing after 15m43s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:25:46 +09:00
ByungCheol 511843f1b6 fix(account): findTagsByEntryIds Map key 소문자로 통일 + null guard
Deploy / deploy (push) Failing after 14m54s
JDBC driver마다 column alias의 대소문자를 다르게 반환할 수 있어
entryId → entry_id, tagName → tag_name 으로 소문자 통일.
NPE 방지를 위한 null 체크도 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:20:55 +09:00
ByungCheol 79fc8fea16 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m49s
2026-07-02 01:47:29 +09:00
ByungCheol 190fbe835f perf(account): 내역 목록 태그 조회 N+1 → 배치 단일 쿼리로 개선
list() 에서 항목마다 findTagNamesByEntryId() 를 개별 호출하던 N+1 을
findTagsByEntryIds() 배치 쿼리 1회로 대체.
항목 수가 많을 때 타임아웃/과부하 방지.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 01:47:08 +09:00
ByungCheol 3ceb3c4253 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m44s
2026-07-02 00:45:44 +09:00
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 186aec7bb4 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m59s
2026-06-30 22:35:35 +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
63 changed files with 1754 additions and 134 deletions
+3 -1
View File
@@ -36,6 +36,8 @@ jobs:
DB_PASSWORD: ci_root_pw
REDIS_HOST: redis
REDIS_PORT: '6379'
# 컨텍스트 테스트(@SpringBootTest)가 스키마를 필요로 함(시작 시 기본분류 시더가 테이블 조회) → 운영과 동일하게 스키마 초기화
SQL_INIT_MODE: always
steps:
- name: Checkout
@@ -46,7 +48,7 @@ jobs:
with:
distribution: temurin
java-version: '17'
cache: gradle
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
# clean build 는 test 태스크를 포함 — 단위테스트 실패 시 빌드/배포 차단(게이트)
- name: Build & Test
+8 -31
View File
@@ -18,33 +18,9 @@ jobs:
deploy:
runs-on: ubuntu-latest
# clean build 가 테스트(@SpringBootTest 컨텍스트 로드 포함)를 돌리므로 CI와 동일한 DB/Redis 필요
services:
mariadb:
image: mariadb:11
env:
MARIADB_ROOT_PASSWORD: ci_root_pw
MARIADB_DATABASE: sb_db
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=10s
--health-timeout=5s
--health-retries=15
redis:
image: redis:7
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=10
env:
DB_URL: jdbc:mariadb://mariadb:3306/sb_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
DB_USERNAME: root
DB_PASSWORD: ci_root_pw
REDIS_HOST: redis
REDIS_PORT: '6379'
# 배포 잡은 테스트를 돌리지 않으므로 DB/Redis 서비스 컨테이너가 필요 없다.
# → 서비스 컨테이너 후처리(cleanup) hang 으로 "가짜 실패"가 나던 문제 회피.
# 품질(테스트) 게이트는 ci.yaml 이 같은 push 에서 함께 수행한다.
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -54,13 +30,14 @@ jobs:
with:
distribution: temurin
java-version: '17'
cache: gradle
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
# clean build 는 test 를 포함 — 테스트 실패 시 jar 가 생성되지 않아 배포 중단(게이트)
- name: Build jar (테스트 포함)
# 테스트 없이 jar 만 빌드(bootJar) — 서비스 컨테이너/컨텍스트 테스트가 없어 후처리 hang 이 없다.
# (테스트 게이트는 ci.yaml 이 담당)
- name: Build jar (테스트 제외, bootJar)
run: |
chmod +x ./gradlew
./gradlew --no-daemon clean build
./gradlew --no-daemon clean bootJar
- name: Deploy (SSH + systemd)
env:
+2
View File
@@ -24,6 +24,8 @@ dependencies {
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5'
// 비밀번호 BCrypt 해싱 (전체 Spring Security 미도입, crypto 모듈만 사용)
implementation 'org.springframework.security:spring-security-crypto'
// Apple 로그인 ID 토큰(JWT) 서명 검증 — Apple JWKS(공개키) 기반
implementation 'com.nimbusds:nimbus-jose-jwt:9.40'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
+61 -38
View File
@@ -1,11 +1,11 @@
# Slim Budget — 백엔드 (sb_bt)
# 돈돼지 가계부 — 백엔드 (sb_bt)
Spring Boot 기반의 가계부·게시판 REST API 서버.
Spring Boot 기반의 가계부·자산·게시판 REST API 서버. (구 Slim Budget)
## 기술 스택
- **Spring Boot 3.5** / **Java 17**
- **MyBatis** (XML 매퍼) + **MariaDB**
- **Redis** (세션 저장)
- **Redis** (세션 저장 — DB 이중 저장으로 유실 대비)
- **spring-security-crypto** (BCrypt 비밀번호 해시)
- Lombok, Bean Validation
@@ -13,74 +13,97 @@ Spring Boot 기반의 가계부·게시판 REST API 서버.
```
com.sb.web
├── account # 가계부 (controller / service / mapper / domain / dto)
├── auth # 인증 (로그인/세션, AuthInterceptor)
├── board # 게시판
│ └ invest·budget·category·recurring·quick·ocr·fx·backup 포함
├── auth # 인증 (로그인/세션/구글로그인, AuthInterceptor, 포인트)
├── billing # 인앱결제·구독(Google Play Billing 검증, RTDN)
├── board # 게시판 (글/댓글/추천/공지/이미지)
├── user # 사용자
├── admin # 관리자 (태그 카테고리, 게시판 설정)
└── common # 공통 (예외, 응답, 설정)
├── admin # 관리자 (기본분류·게시판/가입 설정·회원 관리)
└── common # 공통 (예외, 응답, 헬스체크, 설정)
```
## 스키마 자동 초기화
`spring.sql.init.schema-locations` 로 기동 시 적용 (멱등 — `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`):
`spring.sql.init` 로 기동 시 적용. 기본 `mode: never`, 환경변수 **`SQL_INIT_MODE=always`** 로 켜면 실행(멱등 — `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`):
```
classpath:db/member.sql, db/board.sql, db/account.sql, db/dev-seed.sql
schema-locations: db/member.sql, db/board.sql, db/account.sql, db/dev-seed.sql
continue-on-error: true
```
주요 테이블: `member`, `wallet`, `account_entry`, `account_tag`, `account_entry_tag`,
`account_category`, `budget`, `recurring`, `invest_holding`, `invest_trade`,
`post`, `comment`, `tag`, `tag_category` 등.
주요 테이블:
- **회원/인증**: `member`, `auth_session`(세션 DB 백업), `point_history`, `app_setting`(가입 허용 등), `iap_purchase`(인앱결제)
- **가계부**: `wallet`, `account_entry`, `account_category`(`parent_id` 대/소분류), `account_tag`, `account_entry_tag`, `budget`, `budget_income`, `recurring`, `quick_entry`, `invest_holding`, `invest_trade`
- **게시판**: `post`, `comment`, `post_vote`, `comment_vote`, `post_tag`, `tag`, `tag_category`, `tag_category_board`, `board_image`, `report`
- **기타**: `default_category`(기본 분류 템플릿)
## 인증 / 보안
- 로그인 시 토큰 발급, 이후 `Authorization: Bearer <token>` 로 인증
- 로그인/구글로그인 시 토큰 발급, 이후 `Authorization: Bearer <token>` 로 인증
- **구글 로그인**: `POST /api/auth/google` 로 ID 토큰 수신 → **tokeninfo 검증(aud=웹 클라이언트 ID)** 후 세션 발급. 검증된 동일 이메일이면 기존 계정에 `member.google_id` 로 연결
- `AuthInterceptor` 가 현재 사용자(`SessionUser`)를 요청 속성(`CURRENT_MEMBER`)으로 주입
- **세션 이중 저장**: Redis 유실·재시작 시 `auth_session`(DB)에서 복원·재수화
- **무차별 대입 방지**: 실패 시도만 카운트 — 로그인 IP당 10회/10분, 비밀번호 재인증 회원당 5회/10분 초과 시 429
- **봇 가입 차단**: 허니팟 + IP 레이트리밋(Redis)
- **모든 가계부 데이터는 `member_id`(소유자)로 격리** — 본인 데이터만 조회/수정
- DB 자격증명 등은 `.env`(gitignore)로 관리, `.env.example` 템플릿 제공
- DB/Redis 자격증명·`ACCOUNT_CRYPTO_KEY`(계좌번호 AES-256-GCM)·`GOOGLE_CLIENT_ID` 등은 `.env`(gitignore), `.env.example` 템플릿 제공
## REST API 개요
### 인증 · 사용자 · 관리자
- `POST /api/auth/login` 외 인증 엔드포인트
- `/api/users` — 사용자 관리
- `/api/admin/**` — 태그 카테고리·게시판 설정(관리자)
- `/api/board/**` — 게시판(글/댓글/태그/열람제한)
- `/api/auth``login`·`signup`·`logout`·`google`·`me`(GET/DELETE) / `password`·`verify-password`·`profile`·`profile-image` / `points`·`point-history` / `signup-enabled`·`google-client-id`
- `/api/users` — 사용자 CRUD
- `/api/admin``signup-setting`(가입 허용 토글), `default-categories`(기본 분류), `members`(회원 목록·`role`·`plan`·`status` 변경·삭제), 태그 카테고리
- `/api/billing``products`·`subscription`·`google/verify`·`cancel`·`resume`·`restore`·`google/rtdn`(정기 알림)
- `/api/board` — 글/댓글 CRUD, `posts/{id}/vote`(추천), `notice`/`unnotice`(공지), `block`/`unblock`(열람제한), `my/posts`·`my/comments`, `tag-groups`, `recommenders`
- `/api/board/images` (업로드) · `/api/images/{id}` (공개 조회) — 게시판 이미지(DB 저장, 절대 URL 반환)
### 가계부 `/api/account`
| 메서드·경로 | 설명 |
|---|---|
| `GET/POST/PUT/DELETE /entries` | 내역 CRUD. 조회 필터: `year,month,type,category,walletId,keyword,tagId` |
| `POST /entries/notification` `POST /entries/parse` | 카드/은행 알림 자동인식 등록 · 자연어 파싱 |
| `GET /entries/pending/count` `POST /entries/{id}/confirm` | 확인 필요(pending) 건수 · 확정 |
| `GET /summary` `GET /stats` | 월 요약 / 기간 버킷(일·주·월·년) 수입·지출 |
| `GET /category-stats` | 분류별 합계 (파이차트) |
| `GET/POST/PUT/DELETE /wallets` | 계좌/카드/대출/투자(BANK·CARD·LOAN·INVEST) |
| `GET /networth` `GET /networth/trend` | 순자산 / 월별 순자산 추이 |
| `GET /wallets/{id}/entries` `POST /repayment` | 계좌별 내역 / 상환·납부(원금=이체, 이자=지출) |
| `GET/POST/PUT/DELETE /tags` | 사용자별 가계부 태그 |
| `/api/account/categories` (+`/import`) | 분류 CRUD, 기존 내역에서 가져오기 |
| `/api/account/budgets` (+`/status`,`/period`) | 예산 CRUD, 진행률, 기간별 |
| `/api/account/recurrings` (+`/run`) | 정기 거래 규칙, 밀린 회차 자동 생성 |
| `/api/account/invest/**` | 투자 포트폴리오(아래) |
| `GET /category-stats` | 분류별 합계 (파이차트, 대/소분류) |
| `GET/POST/PUT/DELETE /wallets` (+`/reorder`) | 계좌(BANK·CASH·CARD·LOAN·INVEST) |
| `GET /networth` `GET /networth/trend` | 순자산 / 순자산 추이(`unit=WEEK&weeks=N` 주간 지원) |
| `GET /wallets/{id}/entries` `POST /repayment` | 계좌별 내역 / 상환·납부(원금=이체·이자=지출·연회비=지출) |
| `GET/POST/PUT/DELETE /tags` (+`/reorder`) | 사용자별 가계부 태그 |
| `/categories` (+`/reorder`,`/import`) | 분류 CRUD, 순서변경, 기존 내역에서 가져오기 |
| `/budgets` (+`/income`,`/copy`,`/status`,`/period`) | 월별 예산 CRUD, 수입, 전월 복사, 진행률, 기간별 |
| `/recurrings` (+`/run`) | 고정지출 규칙, 밀린 회차 자동 생성 |
| `/quick-entries` | 자주 쓰는 내역 |
| `/invest/**` | 투자 포트폴리오(아래) |
| `/ocr/receipt` | 영수증 OCR (Google Vision) |
| `POST /restore` | 백업 데이터 복구 (엑셀·JSON) |
> 환율: `GET /api/fx/rate` — 외화 입력 시 환율 자동조회(원화 환산).
### 잔액 · 순자산 모델
- 부호 모델: 자산(+) / 부채(−)
- 부호 모델: 자산(+) / 부채(−). 현금(CASH)은 은행(BANK)과 동일하게 자산 합산
- 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금 (`WalletMapper.findBalances`)
- 순자산 = Σ자산 − Σ부채. 투자계좌는 **예수금 + 주식 평가금액**으로 반영
- 순자산 추이: 현금흐름 누적(이체는 상쇄) + 투자 손익은 현재 시점에 가산
- 순자산 추이: 현금흐름 누적(이체는 상쇄) + 투자 손익은 현재 시점에 가산. 주간(월요일 기준) 집계 지원
### 정기 거래 (recurring)
### 고정지출 (recurring)
- 빈도(DAILY/WEEKLY/MONTHLY/YEARLY) + 일/요일/월 지정
- `last_run_date` 커서로 **중복 없이** 밀린 회차를 따라잡아 `account_entry` 생성
- `last_run_date` 커서로 **중복 없이** 밀린 회차를 따라잡아 `account_entry` 생성. 자동 생성분은 **'확인 필요'** 상태로 만들어 실제 처리 확인 후 확정
### 투자 포트폴리오 (`/api/account/invest`) — 종목 단위(C)
### 투자 포트폴리오 (`/api/account/invest`) — 종목 단위
| 경로 | 설명 |
|---|---|
| `GET/POST/PUT/DELETE /holdings` | 보유 종목 (종목명·코드·현재가). 집계: 수량·평단·평가금액·평가손익·수익률·실현손익 |
| `GET/POST /holdings/{id}/trades`, `DELETE /trades/{id}` | 매수/매도 이력 |
- 매매이력(시간순)으로 **이동평균 평단**·실현손익·평가손익 산출
-수 시 평단 재계산 `(기존수량×평단 + 매수수량×단가 + 수수료)/총수량`, 매도 시 실현손익 = (매도가 − 평단)×수량 − 수수료
- 매도수량이 보유수량 초과 시 400 반환
| `GET/POST/PUT/DELETE /holdings` | 보유 종목(종목명·코드·현재가). 집계: 수량·평단·평가금액·평가손익·수익률·실현손익 |
| `GET/POST /holdings/{id}/trades`, `PUT/DELETE /trades/{id}` | 매수/매도 이력 (수정 시 시점별 시뮬레이션으로 보유수량 음수 방지) |
| `POST /holdings/refresh-prices`, `POST /refresh-prices` | 종목코드로 현재가 자동 갱신(네이버 금융) |
-매이력(시간순)으로 **이동평균 평단**·실현손익·평가손익 산출. 매도수량이 보유수량 초과 시 400
- 투자계좌 가치 = 예수금(현금 ± 매매) + 주식평가, 순자산/추이에 연동
- 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외)
- ℹ️ 백엔드는 종목 단위 API를 유지하나, **현재 프론트 UI 는 투자금+평가액만 입력하는 단순형**으로 노출
## 개발/실행
```bash
cp .env.example .env # DB/Redis 자격증명 입력
cp .env.example .env # DB/Redis 자격증명 입력 (SQL_INIT_MODE=always 로 스키마 자동생성)
./gradlew bootRun # http://localhost:8080
./gradlew test # 단위 + @WebMvcTest 통합 테스트
```
## 배포
- `main` 푸시 → Gitea Actions 가 CI(빌드·테스트) + Deploy(jar 전송·systemd 재시작) 실행. 테스트 실패 시 배포 중단
- 상세: [deploy/README.md](../deploy/README.md)
@@ -56,7 +56,9 @@ public class AccountController {
public ResponseEntity<WalletResponse> createWallet(
@Valid @RequestBody WalletRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return ResponseEntity.status(HttpStatus.CREATED).body(accountService.createWallet(req, current.getId()));
boolean premium = "ADMIN".equals(current.getRole()) || "PREMIUM".equals(current.getPlan());
return ResponseEntity.status(HttpStatus.CREATED)
.body(accountService.createWallet(req, current.getId(), premium));
}
@PutMapping("/wallets/{id}")
@@ -250,6 +252,14 @@ public class AccountController {
return accountService.parseText(req);
}
/** 통계 화면 집계 → AI 재무 코멘트. PREMIUM 무제한, FREE 는 크레딧 차감. */
@PostMapping("/ai-comment")
public com.sb.web.account.dto.AiCommentResponse aiComment(
@RequestBody com.sb.web.account.dto.AiCommentRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return accountService.aiComment(req, current);
}
/** 미확인 내역 확정 (분류/계좌 보정) */
@PostMapping("/entries/{id}/confirm")
public AccountEntryResponse confirm(
@@ -6,10 +6,11 @@ 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.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
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;
/**
@@ -30,4 +31,11 @@ public class BackupController {
backupService.restore(current.getId(), req);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/all")
public ResponseEntity<Void> deleteAll(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
backupService.deleteAll(current.getId());
return ResponseEntity.noContent().build();
}
}
@@ -1,11 +1,14 @@
package com.sb.web.account.controller;
import com.sb.web.account.dto.BudgetOnetimeRequest;
import com.sb.web.account.dto.BudgetOnetimeResponse;
import com.sb.web.account.dto.BudgetPeriodStat;
import com.sb.web.account.dto.BudgetRequest;
import com.sb.web.account.dto.BudgetResponse;
import com.sb.web.account.dto.BudgetStatus;
import com.sb.web.account.dto.ExpectedIncomeRequest;
import com.sb.web.account.service.AccountSettingService;
import com.sb.web.account.service.BudgetOnetimeService;
import com.sb.web.account.service.BudgetService;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor;
@@ -26,6 +29,7 @@ import java.util.List;
public class BudgetController {
private final BudgetService budgetService;
private final BudgetOnetimeService onetimeService;
private final AccountSettingService settingService;
/** 월 예상 수입 조회 (연·월별) */
@@ -50,8 +54,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 +92,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}")
@@ -94,4 +114,35 @@ public class BudgetController {
budgetService.delete(id, current.getId());
return ResponseEntity.noContent().build();
}
// ===== 1회성 예산 =====
@GetMapping("/onetime")
public List<BudgetOnetimeResponse> onetimeList(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return onetimeService.list(current.getId());
}
@PostMapping("/onetime")
public ResponseEntity<BudgetOnetimeResponse> onetimeCreate(
@Valid @RequestBody BudgetOnetimeRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return ResponseEntity.status(HttpStatus.CREATED).body(onetimeService.create(req, current.getId()));
}
@PutMapping("/onetime/{id}")
public BudgetOnetimeResponse onetimeUpdate(
@PathVariable Long id,
@Valid @RequestBody BudgetOnetimeRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return onetimeService.update(id, req, current.getId());
}
@DeleteMapping("/onetime/{id}")
public ResponseEntity<Void> onetimeDelete(
@PathVariable Long id,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
onetimeService.delete(id, current.getId());
return ResponseEntity.noContent().build();
}
}
@@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@@ -28,10 +29,12 @@ public class FxController {
public Map<String, Object> rate(@RequestParam String from,
@RequestParam(defaultValue = "KRW") String to) {
BigDecimal rate = fxService.getRate(from, to);
Instant updatedAt = fxService.getCachedAt(from);
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
res.put("updatedAt", updatedAt != null ? updatedAt.toString() : null);
return res;
}
}
@@ -1,5 +1,7 @@
package com.sb.web.account.controller;
import com.sb.web.account.dto.ParsedEntryResponse;
import com.sb.web.account.service.AiEntryParser;
import com.sb.web.account.service.VisionOcrService;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor;
@@ -14,11 +16,15 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 영수증 OCR API (/api/account/ocr/** — 로그인 필요).
* 이미지 업로드 → Google Vision 으로 텍스트 추출 → 프론트에서 금액·날짜·상호 파싱.
* - /receipt : Google Vision 텍스트 추출(프론트 규칙 파서용, 폴백)
* - /receipt-ai: Claude Vision 으로 영수증을 구조화(금액·상호·날짜·분류·계좌). AI 미설정/실패 시 recognized=false.
*/
@RestController
@RequestMapping("/api/account/ocr")
@@ -26,6 +32,7 @@ import java.util.Map;
public class OcrController {
private final VisionOcrService visionOcrService;
private final AiEntryParser aiEntryParser;
@PostMapping("/receipt")
public Map<String, String> receipt(
@@ -37,4 +44,34 @@ public class OcrController {
String text = visionOcrService.recognize(image.getBytes());
return Map.of("text", text);
}
/**
* 영수증 이미지 → AI 구조화. AI 미설정/실패/미인식이면 recognized=false 로 반환(프론트가 /receipt 폴백).
* 분류·계좌 후보는 멀티파트 파트 수 제한(Tomcat 기본 10개)을 피하려 '줄바꿈'으로 합친 단일 필드로 받는다.
*/
@PostMapping("/receipt-ai")
public ParsedEntryResponse receiptAi(
@RequestParam("image") MultipartFile image,
@RequestParam(value = "categoryHints", required = false) String categoryHints,
@RequestParam(value = "walletHints", required = false) String walletHints,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) throws IOException {
if (image == null || image.isEmpty()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "영수증 이미지를 첨부하세요.");
}
return aiEntryParser
.parseReceipt(image.getBytes(), image.getContentType(), LocalDate.now(),
splitLines(categoryHints), splitLines(walletHints))
.orElseGet(() -> ParsedEntryResponse.builder().recognized(false).build());
}
/** 줄바꿈으로 합쳐 온 후보 문자열 → 리스트(공백 항목 제거). null/빈 값이면 빈 리스트. */
private static List<String> splitLines(String joined) {
if (joined == null || joined.isBlank()) return List.of();
List<String> out = new ArrayList<>();
for (String s : joined.split("\n")) {
String t = s.trim();
if (!t.isEmpty()) out.add(t);
}
return out;
}
}
@@ -34,7 +34,9 @@ public class RecurringController {
public ResponseEntity<RecurringResponse> create(
@Valid @RequestBody RecurringRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return ResponseEntity.status(HttpStatus.CREATED).body(recurringService.create(req, current.getId()));
boolean premium = "ADMIN".equals(current.getRole()) || "PREMIUM".equals(current.getPlan());
return ResponseEntity.status(HttpStatus.CREATED)
.body(recurringService.create(req, current.getId(), premium));
}
@PutMapping("/{id}")
@@ -0,0 +1,37 @@
package com.sb.web.account.controller;
import com.sb.web.account.dto.ShopStatusResponse;
import com.sb.web.account.service.ShopService;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/account/shop")
@RequiredArgsConstructor
public class ShopController {
private final ShopService shopService;
@GetMapping("/status")
public ShopStatusResponse status(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return shopService.getStatus(current.getId());
}
@PostMapping("/buy")
public ShopStatusResponse buy(
@RequestBody Map<String, String> body,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
String item = body.get("item");
return switch (item) {
case "WALLET_SLOT" -> shopService.buyWalletSlot(current.getId());
case "AI_STAT" -> shopService.buyAiStatPack(current.getId());
default -> throw new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.BAD_REQUEST, "SHOP_UNKNOWN_ITEM");
};
}
}
@@ -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,29 @@
package com.sb.web.account.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 1회성 예산 — 특정 기간(start_date~end_date)에 단발 예산을 설정.
* category=null 이면 전체 지출 합산, 값 있으면 해당 카테고리만.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BudgetOnetime {
private Long id;
private Long memberId;
private String title;
private String category; // null = 전체
private Long amount;
private LocalDate startDate;
private LocalDate endDate;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -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,12 @@ 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 Long loanPayment; // 월 상환금(고정, 원리금균등) — 은행 조회액과 정확히 맞추기 위한 선택 입력 (LOAN 전용)
private Integer sortOrder; // 표시 순서 (타입 내 정렬)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -0,0 +1,27 @@
package com.sb.web.account.dto;
import lombok.Data;
import java.util.List;
/**
* 통계 화면의 이번 달 집계(원 단위) — AI 재무 코멘트 생성용. 거래 원문은 보내지 않고 집계만 전달.
*/
@Data
public class AiCommentRequest {
private int year;
private int month;
private long income; // 이번 달 수입 합계
private long expense; // 이번 달 지출 합계
private long prevExpense; // 직전 달 지출 합계(대비용)
private long budget; // 이번 달 예산 합계
private long spent; // 이번 달 예산 대비 소비액
private List<CategoryAmount> categories; // 분류별 지출 상위
@Data
public static class CategoryAmount {
private String category;
private long total;
}
}
@@ -0,0 +1,17 @@
package com.sb.web.account.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* AI 재무 코멘트 결과. bullets 가 비어 있으면 통계 화면에서 카드를 숨긴다.
*/
@Data
@Builder
public class AiCommentResponse {
private List<String> bullets; // 현황 관찰/코멘트
private List<String> tips; // 절약 가이드(실행 가능한 제안)
}
@@ -0,0 +1,30 @@
package com.sb.web.account.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDate;
@Data
public class BudgetOnetimeRequest {
@NotBlank(message = "제목을 입력하세요.")
@Size(max = 100, message = "제목은 100자 이내여야 합니다.")
private String title;
@Size(max = 50, message = "카테고리는 50자 이내여야 합니다.")
private String category; // null 또는 빈값 = 전체 지출
@NotNull(message = "금액을 입력하세요.")
@Positive(message = "금액은 0보다 커야 합니다.")
private Long amount;
@NotNull(message = "시작일을 입력하세요.")
private LocalDate startDate;
@NotNull(message = "종료일을 입력하세요.")
private LocalDate endDate;
}
@@ -0,0 +1,41 @@
package com.sb.web.account.dto;
import com.sb.web.account.domain.BudgetOnetime;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDate;
@Data
@Builder
public class BudgetOnetimeResponse {
private Long id;
private String title;
private String category; // null = 전체
private Long amount;
private LocalDate startDate;
private LocalDate endDate;
private long spent;
private long remaining;
private String status; // UPCOMING / ACTIVE / ENDED
public static BudgetOnetimeResponse from(BudgetOnetime b, long spent) {
LocalDate today = LocalDate.now();
String status;
if (today.isBefore(b.getStartDate())) status = "UPCOMING";
else if (today.isAfter(b.getEndDate())) status = "ENDED";
else status = "ACTIVE";
return BudgetOnetimeResponse.builder()
.id(b.getId())
.title(b.getTitle())
.category(b.getCategory())
.amount(b.getAmount())
.startDate(b.getStartDate())
.endDate(b.getEndDate())
.spent(spent)
.remaining(b.getAmount() - spent)
.status(status)
.build();
}
}
@@ -2,8 +2,11 @@ package com.sb.web.account.dto;
import lombok.Data;
import java.util.List;
/**
* 카드 결제 푸시 알림 원문 (네이티브 알림 리스너 → 백엔드).
* 빠른입력(자연어) 파싱 시에는 사용자의 분류·계좌 후보 목록을 함께 실어 AI가 그 중에서 고르게 한다.
*/
@Data
public class NotificationRequest {
@@ -16,4 +19,10 @@ public class NotificationRequest {
/** 알림 본문 */
private String text;
/** (선택) AI 파싱용 분류 후보 이름 목록 — 이 중에서 카테고리를 고르게 함(없으면 미추천) */
private List<String> categoryHints;
/** (선택) AI 파싱용 결제수단(계좌) 후보 이름 목록 — 텍스트에 결제수단 언급 시 이 중에서 고름 */
private List<String> walletHints;
}
@@ -13,8 +13,11 @@ import java.time.LocalDate;
public class ParsedEntryResponse {
private boolean recognized; // 거래로 인식되었는가
private String type; // INCOME / EXPENSE
private String type; // INCOME / EXPENSE / TRANSFER
private long amount;
private String merchant; // 가맹점/적요(메모 후보)
private LocalDate date;
private String category; // AI 추천 분류(후보 목록 중). 없으면 빈 문자열
private String wallet; // AI 추천 계좌(후보 중). 지출/수입=결제수단, 이체=보내는(출금) 계좌
private String toWallet; // 이체 시 받는(입금) 계좌(후보 중). 아니면 빈 문자열
}
@@ -0,0 +1,15 @@
package com.sb.web.account.dto;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ShopStatusResponse {
private int extraWalletSlots;
private int aiStatCredits;
private int maxExtraSlots;
private int walletSlotPrice;
private int aiStatPrice;
private int aiStatPackSize;
}
@@ -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|CASH|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.")
@Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST|MINUS", message = "유형이 올바르지 않습니다.")
private String type;
@NotBlank(message = "이름을 입력하세요.")
@@ -37,4 +40,18 @@ 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; // 대출시작일
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 선택
}
@@ -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,14 @@ 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; // 대출시작일
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 선택 입력
public static WalletResponse from(Wallet w, long balance) {
return WalletResponse.builder()
.id(w.getId())
@@ -43,6 +52,12 @@ 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())
.loanPayment(w.getLoanPayment())
.build();
}
}
@@ -93,4 +93,12 @@ public interface AccountEntryMapper {
int deleteEntryTagsByEntryId(@Param("entryId") Long entryId);
List<String> findTagNamesByEntryId(@Param("entryId") Long entryId);
/** 복수 항목의 태그를 한 번에 조회 — {entryId, tagName} 맵 반환 */
List<Map<String, Object>> findTagsByEntryIds(@Param("entryIds") List<Long> entryIds);
/** 특정 연·월에 기록이 있는 서로 다른 날짜 수 (한 달 개근 판정용) */
int countDistinctEntryDays(@Param("memberId") Long memberId,
@Param("year") int year,
@Param("month") int month);
}
@@ -14,6 +14,7 @@ public interface BackupMapper {
int deleteRecurrings(@Param("memberId") Long memberId);
int deleteBudgets(@Param("memberId") Long memberId);
int deleteBudgetIncome(@Param("memberId") Long memberId);
int deleteBudgetOnetime(@Param("memberId") Long memberId);
int deleteQuickEntries(@Param("memberId") Long memberId);
int deleteInvestTrades(@Param("memberId") Long memberId);
int deleteInvestHoldings(@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);
@@ -0,0 +1,27 @@
package com.sb.web.account.mapper;
import com.sb.web.account.domain.BudgetOnetime;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
@Mapper
public interface BudgetOnetimeMapper {
List<BudgetOnetime> findByMember(@Param("memberId") Long memberId);
BudgetOnetime findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
long sumExpenseInRange(@Param("memberId") Long memberId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("category") String category);
void insert(BudgetOnetime b);
void update(BudgetOnetime b);
void delete(@Param("id") Long id, @Param("memberId") Long memberId);
}
@@ -15,6 +15,9 @@ public interface RecurringMapper {
List<Recurring> findByMember(@Param("memberId") Long memberId);
/** 회원의 정기 거래 개수 (무료 회원 등록 한도 판정용) */
int countByMember(@Param("memberId") Long memberId);
/** 활성 규칙 (자동 생성용) */
List<Recurring> findActiveByMember(@Param("memberId") Long memberId);
@@ -31,4 +31,7 @@ public interface WalletMapper {
/** 해당 타입의 현재 최대 sort_order (신규 계좌를 맨 뒤에 배치) */
Integer maxSortOrder(@Param("memberId") Long memberId, @Param("type") String type);
/** 해당 타입의 계좌 개수 (무료 회원 종류별 등록 한도 판정용) */
int countByType(@Param("memberId") Long memberId, @Param("type") String type);
}
@@ -21,7 +21,10 @@ import com.sb.web.account.dto.WalletResponse;
import com.sb.web.account.mapper.AccountEntryMapper;
import com.sb.web.account.mapper.AccountTagMapper;
import com.sb.web.account.mapper.WalletMapper;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.mapper.MemberMapper;
import com.sb.web.common.exception.ApiException;
import com.sb.web.point.PointService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -48,14 +51,29 @@ public class AccountService {
private final WalletMapper walletMapper;
private final InvestService investService;
private final CardNotificationParser notificationParser;
private final AiEntryParser aiEntryParser;
private final PointService pointService;
private final MemberMapper memberMapper;
/* ===================== 항목 ===================== */
public List<AccountEntryResponse> list(Long memberId, Integer year, Integer month,
String type, String category, Long walletId, String keyword, Long tagId) {
return mapper.findByMember(memberId, year, month,
blankToNull(type), blankToNull(category), walletId, blankToNull(keyword), tagId).stream()
.map(this::toResponse)
List<AccountEntry> entries = mapper.findByMember(memberId, year, month,
blankToNull(type), blankToNull(category), walletId, blankToNull(keyword), tagId);
if (entries.isEmpty()) return List.of();
// 태그 N+1 방지: 한 번에 모든 entry 의 태그를 조회 후 Map 으로 그룹화
List<Long> ids = entries.stream().map(AccountEntry::getId).toList();
Map<Long, List<String>> tagsByEntry = new HashMap<>();
for (Map<String, Object> row : mapper.findTagsByEntryIds(ids)) {
Object rawId = row.get("entry_id");
String name = (String) row.get("tag_name");
if (rawId == null || name == null) continue;
Long eid = ((Number) rawId).longValue();
tagsByEntry.computeIfAbsent(eid, k -> new ArrayList<>()).add(name);
}
return entries.stream()
.map(e -> AccountEntryResponse.from(e, tagsByEntry.getOrDefault(e.getId(), List.of())))
.toList();
}
@@ -240,9 +258,29 @@ public class AccountService {
.build();
mapper.insert(entry);
applyTags(entry.getId(), req.getTagIds(), memberId);
pointService.awardAccountEntry(memberId);
awardIfMonthComplete(entry.getEntryDate(), memberId);
return toResponse(mapper.findByIdAndMember(entry.getId(), memberId));
}
/**
* 내역의 월이 '현재 진행 중인 달'이고 그 달 모든 날에 기록이 있으면 개근 보너스(랜덤 1~100P) 지급.
* 지난달을 소급 입력해 채우는 파밍을 막기 위해 현재 월만 인정한다(전 월 제외). 판정 실패는 저장에 영향 없음.
*/
private void awardIfMonthComplete(java.time.LocalDate entryDate, Long memberId) {
if (entryDate == null) return;
try {
java.time.LocalDate today = java.time.LocalDate.now();
if (entryDate.getYear() != today.getYear() || entryDate.getMonthValue() != today.getMonthValue()) return;
int recorded = mapper.countDistinctEntryDays(memberId, entryDate.getYear(), entryDate.getMonthValue());
if (recorded >= entryDate.lengthOfMonth()) {
pointService.awardMonthComplete(memberId, entryDate.getYear(), entryDate.getMonthValue());
}
} catch (Exception ignore) {
// 개근 판정 오류는 무시(내역 저장은 이미 완료)
}
}
@Transactional
public AccountEntryResponse update(Long id, AccountEntryRequest req, Long memberId) {
AccountEntry entry = mustFind(id, memberId);
@@ -284,10 +322,19 @@ public class AccountService {
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
*/
/** 문자/푸시 텍스트만 파싱해 폼 자동채움용 결과 반환 (저장 안 함). iPhone 등 알림 자동수집 불가 대비. */
/**
* 문자/푸시·자연어 한 줄 텍스트를 파싱해 폼 자동채움용 결과 반환 (저장 안 함).
* AI 파서(Claude Haiku)가 설정돼 있으면 우선 시도("어제 스타벅스 5천원" 같은 자유 입력 처리),
* 미설정/실패/미인식이면 기존 규칙기반 카드알림 파서로 폴백.
*/
public ParsedEntryResponse parseText(NotificationRequest req) {
CardNotificationParser.Parsed p =
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
java.time.LocalDate today = java.time.LocalDate.now();
java.util.Optional<ParsedEntryResponse> ai =
aiEntryParser.parse(req.getText(), today, req.getCategoryHints(), req.getWalletHints());
if (ai.isPresent() && ai.get().isRecognized()) {
return ai.get();
}
CardNotificationParser.Parsed p = notificationParser.parse(req.getTitle(), req.getText(), today);
return ParsedEntryResponse.builder()
.recognized(p.isRecognized())
.type(p.isIncome() ? "INCOME" : "EXPENSE")
@@ -297,6 +344,32 @@ public class AccountService {
.build();
}
/** 통계 화면 집계 → AI 재무 코멘트.
* - PREMIUM: 무제한.
* - FREE + ai_stat_credits > 0: 크레딧 1 차감 후 호출.
* - FREE + credits == 0: 402 에러(프론트가 상점으로 유도).
*/
@Transactional
public com.sb.web.account.dto.AiCommentResponse aiComment(
com.sb.web.account.dto.AiCommentRequest req, SessionUser current) {
if ("FREE".equals(current.getPlan())) {
int credits = memberMapper.getAiStatCredits(current.getId());
if (credits <= 0) {
throw new ApiException(HttpStatus.PAYMENT_REQUIRED, "AI_STAT_CREDIT_EMPTY");
}
memberMapper.decrementAiStatCredit(current.getId());
}
try {
String json = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(req);
return aiEntryParser.financeComment(json);
} catch (ApiException e) {
throw e;
} catch (Exception e) {
return com.sb.web.account.dto.AiCommentResponse.builder()
.bullets(java.util.List.of()).tips(java.util.List.of()).build();
}
}
@Transactional
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
CardNotificationParser.Parsed p =
@@ -501,8 +574,27 @@ public class AccountService {
.build();
}
// 무료 회원 계좌 등록 한도: 종류(은행·현금·카드·대출·증권·마이너스)별 기본 2개.
// 포인트 상점(추후)에서 영구 보너스 슬롯을 구매하면 종류별 최대 5개까지 확장 가능.
public static final int FREE_WALLET_BASE_LIMIT = 2;
public static final int FREE_WALLET_MAX_LIMIT = 5;
/** 무료 회원의 해당 타입 등록 한도 = 기본 2 + 포인트 상점 구매 슬롯, 최대 5. */
private int freeWalletLimit(Long memberId, String type) {
int bonus = memberMapper.getExtraWalletSlots(memberId);
return Math.min(FREE_WALLET_BASE_LIMIT + bonus, FREE_WALLET_MAX_LIMIT);
}
@Transactional
public WalletResponse createWallet(WalletRequest req, Long memberId) {
public WalletResponse createWallet(WalletRequest req, Long memberId, boolean premium) {
// 유료(또는 관리자)는 무제한. 무료는 종류별 한도 검증.
if (!premium) {
int limit = freeWalletLimit(memberId, req.getType());
if (walletMapper.countByType(memberId, req.getType()) >= limit) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"무료 회원은 종류별 계좌를 " + limit + "개까지 등록할 수 있어요. 유료로 업그레이드하면 무제한이에요.");
}
}
Wallet wallet = toWallet(req);
wallet.setMemberId(memberId);
Integer max = walletMapper.maxSortOrder(memberId, req.getType());
@@ -530,11 +622,18 @@ public class AccountService {
wallet.setType(req.getType());
wallet.setName(req.getName().trim());
wallet.setIssuer(blankToNull(req.getIssuer()));
wallet.setAccountNumber("BANK".equals(req.getType()) ? blankToNull(req.getAccountNumber()) : null);
wallet.setAccountNumber(("BANK".equals(req.getType()) || "MINUS".equals(req.getType())) ? blankToNull(req.getAccountNumber()) : null);
wallet.setCardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null);
wallet.setOpeningBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L);
wallet.setOpeningDate(req.getOpeningDate());
wallet.setCurrentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null);
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);
wallet.setLoanPayment(isLoan ? req.getLoanPayment() : null);
walletMapper.update(wallet);
return WalletResponse.from(wallet, balanceMap(memberId).getOrDefault(id, wallet.getOpeningBalance()));
}
@@ -564,15 +663,22 @@ public class AccountService {
}
private Wallet toWallet(WalletRequest req) {
boolean isLoan = "LOAN".equals(req.getType());
return Wallet.builder()
.type(req.getType())
.name(req.getName().trim())
.issuer(blankToNull(req.getIssuer()))
.accountNumber("BANK".equals(req.getType()) ? blankToNull(req.getAccountNumber()) : null)
.accountNumber(("BANK".equals(req.getType()) || "MINUS".equals(req.getType())) ? blankToNull(req.getAccountNumber()) : null)
.cardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null)
.openingBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L)
.openingDate(req.getOpeningDate())
.currentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null)
.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)
.loanPayment(isLoan ? req.getLoanPayment() : null)
.build();
}
@@ -0,0 +1,270 @@
package com.sb.web.account.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sb.web.account.dto.AiCommentResponse;
import com.sb.web.account.dto.ParsedEntryResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 자연어 한 줄 입력("어제 스타벅스 5천원")을 Claude(Haiku)로 파싱해 폼 자동채움 필드로 변환한다.
* 키(app.anthropic-api-key) 미설정이거나 호출 실패 시 Optional.empty() → 호출부가 규칙기반 파서로 폴백.
* AI는 폼 prefill 용도이며 저장은 사용자 확인 후.
*/
@Slf4j
@Component
public class AiEntryParser {
/** Anthropic API 키. 미설정 시 AI 파싱 비활성(규칙기반으로 폴백). */
@Value("${app.anthropic-api-key:}")
private String apiKey;
/** 사용 모델. 저렴·빠른 Haiku 권장(학습 미사용). */
@Value("${app.anthropic-model:claude-haiku-4-5}")
private String model;
private final ObjectMapper om = new ObjectMapper();
private final RestClient rest = RestClient.builder().baseUrl("https://api.anthropic.com").build();
public boolean enabled() {
return apiKey != null && !apiKey.isBlank();
}
/** 시작 시 AI 파서 활성 여부·키 로딩 상태를 로그로 남겨 진단을 쉽게 한다(키는 앞부분만 마스킹). */
@jakarta.annotation.PostConstruct
void logConfig() {
if (enabled()) {
String masked = apiKey.length() > 14 ? apiKey.substring(0, 14) + "…(len=" + apiKey.length() + ")" : "(len=" + apiKey.length() + ")";
log.info("[ai-parse] 활성화됨 — model={}, key={}", model, masked);
} else {
log.warn("[ai-parse] 비활성(ANTHROPIC_API_KEY 미설정) — 규칙기반 파서만 사용");
}
}
/** 자유 텍스트 → 파싱 결과. 실패/미설정 시 empty. 분류·계좌 후보를 주면 AI가 그 중에서 골라 채운다. */
public Optional<ParsedEntryResponse> parse(String text, LocalDate today,
List<String> categoryHints, List<String> walletHints) {
if (!enabled() || text == null || text.isBlank()) {
log.info("[ai-parse] 건너뜀 — enabled={}, textBlank={}", enabled(), (text == null || text.isBlank()));
return Optional.empty();
}
log.info("[ai-parse] 요청 수신 — text='{}'", text);
String system = withHints("""
너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입/이체 메모를 구조화한다.
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
{"recognized":boolean,"type":"INCOME"|"EXPENSE"|"TRANSFER","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열","toWallet":"문자열"}
규칙:
- amount: 원 단위 정수. 아라비아 숫자·한글 수 모두 해석한다.
예) "5천원"=5000, "1만2천"=12000, "3만5천원"=35000, "천원"=1000,
"오만원"=50000, "삼만원"=30000, "십만원"=100000, "오천원"=5000, "만원"=10000, "이만오천원"=25000.
- type: 이체/송금/옮김/입출금 간 이동은 TRANSFER. 급여/월급/수입/입금/환불/이자/용돈 등은 INCOME. 그 외 소비는 EXPENSE(기본).
- TRANSFER 판단: "A에서 B로 5만원", "A→B 이체", "카드값 냈어(계좌→카드)" 처럼 내 계좌 사이 이동. 이때 category는 빈 문자열.
- merchant: 가맹점/항목명(예: 스타벅스, 점심, 택시). 이체면 메모(예: 카드대금)나 빈 문자열.
- date: 오늘=__TODAY__ 기준 상대표현 해석(오늘/어제/그저께/엊그제 등). 명시 없으면 오늘.
- category: 지출/수입일 때만. 거래 내용에 알맞은 분류를 아래 '분류 후보'에서 정확히 하나(예: 라면→식비, 택시→교통). 없거나 이체면 빈 문자열. 후보에 없는 값 지어내지 말 것.
- wallet: 지출/수입이면 결제수단(출금 계좌). 이체면 '보내는(출금)' 계좌. 아래 '계좌 후보'에 있는 이름만, 언급 없으면 빈 문자열.
- toWallet: 이체일 때 '받는(입금)' 계좌. '계좌 후보'에 있는 이름만. 이체가 아니거나 언급 없으면 빈 문자열.
- 금액을 못 찾거나 거래가 아니면 recognized=false.
분류 후보: __CATS__
계좌 후보: __WALLETS__
""", today, categoryHints, walletHints);
return callClaude(system, text, 300, today, categoryHints, walletHints);
}
/** 영수증 이미지 → 구조화(Claude Vision). 실패/미설정 시 empty → 호출부가 기존 OCR 로 폴백. */
public Optional<ParsedEntryResponse> parseReceipt(byte[] image, String mediaType, LocalDate today,
List<String> categoryHints, List<String> walletHints) {
if (!enabled() || image == null || image.length == 0) {
log.info("[ai-parse] 영수증 건너뜀 — enabled={}, empty={}", enabled(), (image == null || image.length == 0));
return Optional.empty();
}
log.info("[ai-parse] 영수증 수신 — {} bytes, media={}", image.length, mediaType);
String system = withHints("""
너는 한국어 영수증 판독기다. 첨부된 영수증 이미지를 보고 거래 하나로 구조화한다.
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
{"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열"}
규칙:
- amount: 최종 결제/합계 금액(원 단위 정수, 부가세 포함 총액).
- type: 영수증은 대부분 EXPENSE. 환불 영수증이면 INCOME.
- merchant: 상호명(가맹점).
- date: 영수증의 거래일(YYYY-MM-DD). 안 보이면 오늘=__TODAY__.
- category: 품목/업종에 가장 알맞은 분류를 아래 '분류 후보'에서 정확히 하나 고른다. 없으면 빈 문자열. 후보에 없는 값 지어내지 말 것.
- wallet: 영수증에 카드사/결제수단이 보이고 아래 '결제수단 후보'에 있으면 그 이름. 아니면 빈 문자열.
- 영수증이 아니거나 금액을 못 읽으면 recognized=false.
분류 후보: __CATS__
결제수단 후보: __WALLETS__
""", today, categoryHints, walletHints);
String base64 = Base64.getEncoder().encodeToString(image);
String mt = (mediaType == null || mediaType.isBlank()) ? "image/jpeg" : mediaType;
List<Object> content = List.of(
Map.of("type", "image", "source", Map.of("type", "base64", "media_type", mt, "data", base64)),
Map.of("type", "text", "text", "이 영수증을 위 스키마로 구조화해줘."));
return callClaude(system, content, 400, today, categoryHints, walletHints);
}
/** 이번 달 집계(JSON) → 현황 코멘트 + 절약 가이드. 미설정/실패 시 빈 응답. */
public AiCommentResponse financeComment(String summaryJson) {
if (!enabled() || summaryJson == null || summaryJson.isBlank()) return emptyComment();
try {
String system = """
너는 한국어 개인 가계부 코치다. 아래 사용자의 이번 달 집계(JSON, 금액 단위 원)를 보고 두 가지를 만든다.
1) bullets: 현황 관찰 2~3개. 각 한 문장, 구체적 숫자 인용, 잔소리·과장 없이 담백하게.
(예: 전월 대비 지출 증감, 예산 대비 소비 속도, 특정 분류 과다, 수입 대비 지출 비율)
2) tips: 이 사용자의 지출 패턴에 딱 맞춘 '실행 가능한' 절약 제안 2~3개. 두루뭉술한 원칙 금지,
구체적 분류·습관을 지목. 각 한 문장.
단, 교통·버스·지하철·대중교통 관련 분류는 이미 정기권으로 최저 비용을 사용 중이므로 tips에서 제외한다.
말투: 모든 문장을 정중한 종결어미로 끝낸다. 예) "~입니다", "~합니다", "~할 수 있습니다", "~하는 것이 좋습니다".
반말·음슴체(예: "~함", "~줄이자") 금지.
예시 — bullet: "식비가 이번 달 지출의 42%로 가장 큰 비중을 차지하고 있습니다."
tip: "카페 지출 8만원을 주 2회로 줄이면 약 3만원을 절약할 수 있습니다."
반드시 아래 JSON '하나만' 출력한다(마크다운/설명 금지):
{"bullets":["관찰1","관찰2"],"tips":["절약제안1","절약제안2"]}
데이터가 빈약하면 해당 배열을 빈 배열로.
""";
Map<String, Object> reqBody = Map.of(
"model", model,
"max_tokens", 600,
"system", system,
"messages", List.of(Map.of("role", "user", "content", summaryJson)));
JsonNode resp = rest.post()
.uri("/v1/messages")
.header("x-api-key", apiKey)
.header("anthropic-version", "2023-06-01")
.contentType(MediaType.APPLICATION_JSON)
.body(reqBody)
.retrieve()
.body(JsonNode.class);
String out = stripFences(resp.path("content").path(0).path("text").asText("")).trim();
log.info("[ai-comment] 원문='{}'", out);
String json = extractJson(out);
if (json == null) return emptyComment();
JsonNode root = om.readTree(json);
List<String> bullets = strArray(root.path("bullets"));
List<String> tips = strArray(root.path("tips"));
log.info("[ai-comment] 결과 — 코멘트 {}개, 절약팁 {}개", bullets.size(), tips.size());
return AiCommentResponse.builder().bullets(bullets).tips(tips).build();
} catch (Exception e) {
log.warn("[ai-comment] 실패: {}", e.toString());
return emptyComment();
}
}
private AiCommentResponse emptyComment() {
return AiCommentResponse.builder().bullets(List.of()).tips(List.of()).build();
}
/** JsonNode 배열 → 공백 제거한 문자열 리스트. */
private List<String> strArray(JsonNode arr) {
List<String> out = new ArrayList<>();
if (arr != null && arr.isArray()) {
for (JsonNode n : arr) {
String s = n.asText("").trim();
if (!s.isEmpty()) out.add(s);
}
}
return out;
}
/** 분류·계좌 후보와 오늘 날짜를 시스템 프롬프트 템플릿에 주입. */
private String withHints(String body, LocalDate today, List<String> categoryHints, List<String> walletHints) {
String catList = (categoryHints == null || categoryHints.isEmpty()) ? "(없음)" : String.join(", ", categoryHints);
String walletList = (walletHints == null || walletHints.isEmpty()) ? "(없음)" : String.join(", ", walletHints);
return body.replace("__TODAY__", today.toString()).replace("__CATS__", catList).replace("__WALLETS__", walletList);
}
/** Claude Messages 호출 → 응답 JSON 파싱 → 결과. 후보에 없는 분류·계좌는 버림. 실패/미인식 시 empty. */
private Optional<ParsedEntryResponse> callClaude(String system, Object userContent, int maxTokens,
LocalDate today, List<String> categoryHints, List<String> walletHints) {
try {
Map<String, Object> reqBody = Map.of(
"model", model,
"max_tokens", maxTokens,
"system", system,
"messages", List.of(Map.of("role", "user", "content", userContent)));
JsonNode resp = rest.post()
.uri("/v1/messages")
.header("x-api-key", apiKey)
.header("anthropic-version", "2023-06-01")
.contentType(MediaType.APPLICATION_JSON)
.body(reqBody)
.retrieve()
.body(JsonNode.class);
String out = stripFences(resp.path("content").path(0).path("text").asText("")).trim();
log.info("[ai-parse] 모델 원문='{}'", out);
// 모델이 설명문을 섞어 보내도 첫 '{'~마지막 '}' 만 추출해 JSON 파싱(견고성).
String json = extractJson(out);
if (json == null) {
log.info("[ai-parse] JSON 없음 — 미인식으로 간주");
return Optional.empty();
}
JsonNode j = om.readTree(json);
String rawType = j.path("type").asText("EXPENSE").toUpperCase();
String type = ("INCOME".equals(rawType) || "TRANSFER".equals(rawType)) ? rawType : "EXPENSE";
LocalDate date;
try {
date = LocalDate.parse(j.path("date").asText());
} catch (Exception e) {
date = today;
}
boolean transfer = "TRANSFER".equals(type);
ParsedEntryResponse result = ParsedEntryResponse.builder()
.recognized(j.path("recognized").asBoolean(false))
.type(type)
.amount(Math.max(0, j.path("amount").asLong(0)))
.merchant(j.path("merchant").asText(""))
.date(date)
// 이체는 분류 없음
.category(transfer ? "" : pickFromHints(j.path("category").asText(""), categoryHints))
.wallet(pickFromHints(j.path("wallet").asText(""), walletHints))
.toWallet(transfer ? pickFromHints(j.path("toWallet").asText(""), walletHints) : "")
.build();
log.info("[ai-parse] 결과 — recognized={}, type={}, amount={}, merchant={}, date={}, category={}, wallet={}, toWallet={}",
result.isRecognized(), result.getType(), result.getAmount(), result.getMerchant(), result.getDate(),
result.getCategory(), result.getWallet(), result.getToWallet());
return Optional.of(result);
} catch (Exception e) {
log.warn("[ai-parse] 실패 → 폴백: {}", e.toString());
return Optional.empty();
}
}
/** 모델이 반환한 값이 후보 목록에 실제 존재할 때만 채택(대소문자·공백 무시). 없으면 빈 문자열. */
private String pickFromHints(String value, List<String> hints) {
if (value == null || value.isBlank() || hints == null) return "";
String v = value.trim();
for (String h : hints) {
if (h != null && h.trim().equalsIgnoreCase(v)) return h;
}
return "";
}
/** 설명문이 앞뒤로 붙어도 첫 '{'~마지막 '}' 구간만 잘라 JSON 만 추출. 중괄호 없으면 null. */
private String extractJson(String s) {
int a = s.indexOf('{');
int b = s.lastIndexOf('}');
if (a >= 0 && b > a) return s.substring(a, b + 1);
return null;
}
/** 모델이 ```json ... ``` 로 감쌌을 때 코드펜스 제거. */
private String stripFences(String s) {
String t = s.trim();
if (t.startsWith("```")) {
int nl = t.indexOf('\n');
if (nl >= 0) t = t.substring(nl + 1);
if (t.endsWith("```")) t = t.substring(0, t.length() - 3);
}
return t;
}
}
@@ -38,6 +38,23 @@ public class BackupService {
private final BudgetService budgetService;
private final QuickEntryService quickEntryService;
@Transactional
public void deleteAll(Long memberId) {
backupMapper.deleteEntryTags(memberId);
backupMapper.deleteEntries(memberId);
backupMapper.deleteRecurrings(memberId);
backupMapper.deleteBudgets(memberId);
backupMapper.deleteBudgetIncome(memberId);
backupMapper.deleteBudgetOnetime(memberId);
backupMapper.deleteQuickEntries(memberId);
backupMapper.deleteInvestTrades(memberId);
backupMapper.deleteInvestHoldings(memberId);
backupMapper.deleteCategories(memberId);
backupMapper.deleteTags(memberId);
backupMapper.deleteWallets(memberId);
log.info("[deleteAll] member={} — all account data deleted", memberId);
}
@Transactional
public void restore(Long memberId, RestoreRequest req) {
if (isEmpty(req)) {
@@ -50,6 +67,7 @@ public class BackupService {
backupMapper.deleteRecurrings(memberId);
backupMapper.deleteBudgets(memberId);
backupMapper.deleteBudgetIncome(memberId);
backupMapper.deleteBudgetOnetime(memberId);
backupMapper.deleteQuickEntries(memberId);
backupMapper.deleteInvestTrades(memberId);
backupMapper.deleteInvestHoldings(memberId);
@@ -71,7 +89,8 @@ public class BackupService {
wr.setOpeningBalance(w.getOpeningBalance());
wr.setOpeningDate(w.getOpeningDate());
wr.setCurrentValue(w.getCurrentValue());
Long newId = accountService.createWallet(wr, memberId).getId();
// 복원은 사용자 본인 데이터 복구이므로 무료 한도(종류별 2개)를 적용하지 않는다.
Long newId = accountService.createWallet(wr, memberId, true).getId();
if (w.getOldId() != null) walletMap.put(w.getOldId(), newId);
}
}
@@ -152,15 +171,17 @@ public class BackupService {
rr.setStartDate(r.getStartDate());
rr.setEndDate(r.getEndDate());
rr.setActive(r.getActive() == null || r.getActive());
recurringService.create(rr, memberId);
// 복원은 본인 데이터 복구이므로 무료 한도 미적용
recurringService.create(rr, memberId, true);
}
}
// 7) 예산
// 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);
budgetService.create(b, memberId, today.getYear(), today.getMonthValue());
}
}
@@ -0,0 +1,82 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.BudgetOnetime;
import com.sb.web.account.dto.BudgetOnetimeRequest;
import com.sb.web.account.dto.BudgetOnetimeResponse;
import com.sb.web.account.mapper.BudgetOnetimeMapper;
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 BudgetOnetimeService {
private final BudgetOnetimeMapper mapper;
public List<BudgetOnetimeResponse> list(Long memberId) {
return mapper.findByMember(memberId).stream()
.map(b -> {
long spent = mapper.sumExpenseInRange(
memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
return BudgetOnetimeResponse.from(b, spent);
})
.toList();
}
@Transactional
public BudgetOnetimeResponse create(BudgetOnetimeRequest req, Long memberId) {
validate(req);
BudgetOnetime b = BudgetOnetime.builder()
.memberId(memberId)
.title(req.getTitle().trim())
.category(blankToNull(req.getCategory()))
.amount(req.getAmount())
.startDate(req.getStartDate())
.endDate(req.getEndDate())
.build();
mapper.insert(b);
long spent = mapper.sumExpenseInRange(memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
return BudgetOnetimeResponse.from(b, spent);
}
@Transactional
public BudgetOnetimeResponse update(Long id, BudgetOnetimeRequest req, Long memberId) {
validate(req);
BudgetOnetime b = mustFind(id, memberId);
b.setTitle(req.getTitle().trim());
b.setCategory(blankToNull(req.getCategory()));
b.setAmount(req.getAmount());
b.setStartDate(req.getStartDate());
b.setEndDate(req.getEndDate());
mapper.update(b);
long spent = mapper.sumExpenseInRange(memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
return BudgetOnetimeResponse.from(b, spent);
}
@Transactional
public void delete(Long id, Long memberId) {
mustFind(id, memberId);
mapper.delete(id, memberId);
}
private void validate(BudgetOnetimeRequest req) {
if (req.getEndDate().isBefore(req.getStartDate())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "종료일이 시작일보다 빠를 수 없습니다.");
}
}
private BudgetOnetime mustFind(Long id, Long memberId) {
BudgetOnetime b = mapper.findByIdAndMember(id, memberId);
if (b == null) throw new ApiException(HttpStatus.NOT_FOUND, "1회성 예산을 찾을 수 없습니다.");
return b;
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}
@@ -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()),
@@ -17,7 +17,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 내역·예산 전파, 기본분류 불러오기.
*/
@@ -6,6 +6,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -21,9 +22,17 @@ 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 record Cached(LocalDate date, Instant fetchedAt, JsonNode rates) {}
private final Map<String, Cached> cache = new ConcurrentHashMap<>();
/** from 통화 1단위 → to 통화 환율. 조회 실패 시 null. */
/** 해당 통화 환율이 마지막으로 캐시된 시각. 캐시 없으면 null. */
public Instant getCachedAt(String from) {
if (from == null) return null;
Cached c = cache.get(from.trim().toUpperCase());
return c != null ? c.fetchedAt() : null;
}
/** from 통화 1단위 → to 통화 환율. 조회 실패 시 null. */
public BigDecimal getRate(String from, String to) {
if (from == null || to == null) return null;
@@ -54,7 +63,7 @@ public class FxService {
if (rates.isMissingNode() || !rates.isObject()) {
return c != null ? c.rates() : null;
}
cache.put(base, new Cached(today, rates));
cache.put(base, new Cached(today, Instant.now(), rates));
return rates;
} catch (Exception e) {
log.warn("환율 조회 실패 base={}: {}", base, e.toString());
@@ -30,8 +30,15 @@ public class RecurringService {
return recurringMapper.findByMember(memberId).stream().map(this::toResponse).toList();
}
// 무료 회원 고정지출 등록 한도(써보고 결제 유도). 유료·관리자는 무제한.
public static final int FREE_RECURRING_LIMIT = 2;
@Transactional
public RecurringResponse create(RecurringRequest req, Long memberId) {
public RecurringResponse create(RecurringRequest req, Long memberId, boolean premium) {
if (!premium && recurringMapper.countByMember(memberId) >= FREE_RECURRING_LIMIT) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"무료 회원은 고정 지출을 " + FREE_RECURRING_LIMIT + "개까지 등록할 수 있어요. 유료로 업그레이드하면 무제한이에요.");
}
Recurring r = toEntity(req);
r.setMemberId(memberId);
recurringMapper.insert(r);
@@ -0,0 +1,71 @@
package com.sb.web.account.service;
import com.sb.web.account.dto.ShopStatusResponse;
import com.sb.web.auth.mapper.MemberMapper;
import com.sb.web.point.PointService;
import com.sb.web.point.mapper.PointMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service
@RequiredArgsConstructor
public class ShopService {
static final int MAX_EXTRA_SLOTS = 3;
static final int WALLET_SLOT_PRICE = 500;
static final int AI_STAT_PRICE = 1000;
static final int AI_STAT_PACK_SIZE = 10;
private static final String REASON_SHOP_WALLET_SLOT = "SHOP_WALLET_SLOT";
private static final String REASON_SHOP_AI_STAT = "SHOP_AI_STAT";
private final MemberMapper memberMapper;
private final PointMapper pointMapper;
public ShopStatusResponse getStatus(Long memberId) {
return ShopStatusResponse.builder()
.extraWalletSlots(memberMapper.getExtraWalletSlots(memberId))
.aiStatCredits(memberMapper.getAiStatCredits(memberId))
.maxExtraSlots(MAX_EXTRA_SLOTS)
.walletSlotPrice(WALLET_SLOT_PRICE)
.aiStatPrice(AI_STAT_PRICE)
.aiStatPackSize(AI_STAT_PACK_SIZE)
.build();
}
@Transactional
public ShopStatusResponse buyWalletSlot(Long memberId) {
int slots = memberMapper.getExtraWalletSlots(memberId);
if (slots >= MAX_EXTRA_SLOTS) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "SHOP_WALLET_SLOT_MAX");
}
long points = pointService(memberId);
if (points < WALLET_SLOT_PRICE) {
throw new ResponseStatusException(HttpStatus.PAYMENT_REQUIRED, "SHOP_NOT_ENOUGH_POINTS");
}
pointMapper.insertHistory(memberId, -WALLET_SLOT_PRICE, REASON_SHOP_WALLET_SLOT);
pointMapper.addPoints(memberId, -WALLET_SLOT_PRICE);
memberMapper.incrementExtraWalletSlots(memberId);
return getStatus(memberId);
}
@Transactional
public ShopStatusResponse buyAiStatPack(Long memberId) {
long points = pointService(memberId);
if (points < AI_STAT_PRICE) {
throw new ResponseStatusException(HttpStatus.PAYMENT_REQUIRED, "SHOP_NOT_ENOUGH_POINTS");
}
pointMapper.insertHistory(memberId, -AI_STAT_PRICE, REASON_SHOP_AI_STAT);
pointMapper.addPoints(memberId, -AI_STAT_PRICE);
memberMapper.addAiStatCredits(memberId, AI_STAT_PACK_SIZE);
return getStatus(memberId);
}
private long pointService(Long memberId) {
Long p = pointMapper.getPoints(memberId);
return p == null ? 0L : p;
}
}
@@ -69,6 +69,12 @@ public class AuthController {
return authService.googleLogin(req.getIdToken(), req.isRememberMe());
}
/** 애플 로그인/가입 (비보호) — Sign in with Apple identity token 검증 후 세션 발급 */
@PostMapping("/apple")
public LoginResponse appleLogin(@Valid @RequestBody com.sb.web.auth.dto.AppleLoginRequest req) {
return authService.appleLogin(req.getIdentityToken(), req.getName(), req.isRememberMe());
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest request) {
authService.logout(AuthInterceptor.resolveToken(request));
@@ -96,10 +102,15 @@ public class AuthController {
return java.util.Map.of("points", pointService.getPoints(current.getId()));
}
/** 포인트 적립/차감 내역 (최신순) */
/** 포인트 적립/차감 내역 — year+month 지정 시 해당 월 전건, 미지정 시 최근 100건 */
@GetMapping("/point-history")
public java.util.List<com.sb.web.point.PointHistoryResponse> pointHistory(
@RequestParam(required = false) Integer year,
@RequestParam(required = false) Integer month,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
if (year != null && month != null) {
return pointService.getHistory(current.getId(), year, month);
}
return pointService.getHistory(current.getId());
}
@@ -26,6 +26,7 @@ public class Member implements Serializable {
private String provider; // LOCAL / NAVER / GOOGLE
private String providerId; // 소셜 제공자 고유 ID
private String googleId; // 구글 sub (계정 연결용 LOCAL 계정에 구글을 연결해도 provider 유지)
private String appleId; // 애플 sub (계정 연결용 구글과 동일)
private String googlePicture; // 구글 아바타 URL (로그인 동기화)
private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
private String role; // USER / ADMIN
@@ -0,0 +1,19 @@
package com.sb.web.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 애플 로그인 요청 프론트(Sign in with Apple)에서 받은 identity token(JWT).
* 이름은 애플이 <b>최초 인증 시에만</b> 클라이언트에 주므로, 신규 가입 채우도록 함께 전달받는다.
*/
@Data
public class AppleLoginRequest {
@NotBlank(message = "토큰이 없습니다.")
private String identityToken;
private String name; // 최초 로그인 시에만 제공될 있음(이후 null)
private boolean rememberMe;
}
@@ -31,6 +31,15 @@ public interface MemberMapper {
/** 기존 계정에 구글 sub 연결 (provider 유지). */
int linkGoogle(@Param("id") Long id, @Param("googleId") String googleId);
/** 애플 sub 로 회원 조회 (연결된 LOCAL 계정 + 순수 애플 계정 모두). */
Member findByAppleId(@Param("appleId") String appleId);
/** 애플 로그인 시 연결 대상이 될 같은 이메일의 기존 계정(LOCAL 우선, 애플 미연결만). */
Member findByEmailForAppleLink(@Param("email") String email);
/** 기존 계정에 애플 sub 연결 (provider 유지). */
int linkApple(@Param("id") Long id, @Param("appleId") String appleId);
int countByLoginId(@Param("loginId") String loginId);
int insert(Member member);
@@ -64,4 +73,11 @@ public interface MemberMapper {
int updateStatus(@Param("id") Long id, @Param("status") String status);
int deleteById(@Param("id") Long id);
// 포인트 상점
int getExtraWalletSlots(@Param("id") Long id);
int getAiStatCredits(@Param("id") Long id);
int incrementExtraWalletSlots(@Param("id") Long id);
int addAiStatCredits(@Param("id") Long id, @Param("amount") int amount);
int decrementAiStatCredit(@Param("id") Long id);
}
@@ -45,6 +45,13 @@ public class AuthService {
@org.springframework.beans.factory.annotation.Value("${app.google-client-id:}")
private String googleClientId;
/** 애플 로그인 aud(=iOS 앱 번들 ID). iOS 번들ID는 kr.sblog.slimbudget.app (안드로이드 패키지와 별개). */
@org.springframework.beans.factory.annotation.Value("${app.apple-client-id:kr.sblog.slimbudget.app}")
private String appleClientId;
/** 애플 ID 토큰 검증기(JWKS 캐시 포함). 최초 사용 시 lazy 초기화. */
private volatile com.nimbusds.jwt.proc.ConfigurableJWTProcessor<com.nimbusds.jose.proc.SecurityContext> appleJwtProcessor;
private static final String SESSION_PREFIX = "session:";
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인(로그인 상태 유지)
@@ -241,6 +248,110 @@ public class AuthService {
return googleClientId == null ? "" : googleClientId;
}
/**
* 애플 로그인. Sign in with Apple identity token(JWT) 애플 공개키(JWKS) 검증한 ,
* 구글과 동일한 규칙으로 계정을 조회/연결/생성한다.
*/
public LoginResponse appleLogin(String identityToken, String providedName, boolean rememberMe) {
if (identityToken == null || identityToken.isBlank()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "토큰이 없습니다.");
}
com.nimbusds.jwt.JWTClaimsSet claims;
try {
claims = verifyAppleToken(identityToken);
} catch (Exception e) {
log.warn("[apple] token verify failed: {}", e.toString());
throw new ApiException(HttpStatus.UNAUTHORIZED, "애플 인증에 실패했습니다.");
}
// 발급자·대상(aud=번들 ID)·만료 검증
java.util.Date now = new java.util.Date();
if (claims.getExpirationTime() == null || claims.getExpirationTime().before(now)
|| !"https://appleid.apple.com".equals(claims.getIssuer())
|| claims.getAudience() == null || !claims.getAudience().contains(appleClientId)) {
throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 애플 토큰입니다.");
}
String sub = claims.getSubject();
if (sub == null || sub.isBlank()) {
throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 애플 토큰입니다.");
}
String email;
boolean emailVerified;
try {
email = claims.getStringClaim("email");
Object ev = claims.getClaim("email_verified"); // 문자열("true")/불리언 모두 대응
emailVerified = ev != null && "true".equals(String.valueOf(ev));
} catch (Exception e) {
email = null;
emailVerified = false;
}
String name = (providedName != null && !providedName.isBlank()) ? providedName
: (email != null ? email.split("@")[0] : "사용자");
// 1) 애플 sub 이미 연결/가입된 계정 조회
Member member = memberMapper.findByAppleId(sub);
// 2) 없으면 같은 (검증된)이메일의 기존 계정에 애플 연결 중복 계정/데이터 분리 방지
if (member == null && email != null && !email.isBlank() && emailVerified) {
Member existing = memberMapper.findByEmailForAppleLink(email);
if (existing != null) {
if (!"ACTIVE".equals(existing.getStatus())) {
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
}
memberMapper.linkApple(existing.getId(), sub);
existing.setAppleId(sub);
member = existing;
log.info("[apple] 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("APPLE")
.providerId(sub)
.appleId(sub)
.role("USER")
.status("ACTIVE")
.build();
memberMapper.insert(member);
log.info("[apple] new member id={} email={}", member.getId(), email);
}
if (!"ACTIVE".equals(member.getStatus())) {
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
}
return issueSession(member, rememberMe);
}
/** 애플 identity token 을 애플 JWKS(공개키)로 서명 검증하고 클레임을 반환. */
private com.nimbusds.jwt.JWTClaimsSet verifyAppleToken(String idToken) throws Exception {
com.nimbusds.jwt.proc.ConfigurableJWTProcessor<com.nimbusds.jose.proc.SecurityContext> proc = appleJwtProcessor;
if (proc == null) {
synchronized (this) {
if (appleJwtProcessor == null) {
com.nimbusds.jose.jwk.source.JWKSource<com.nimbusds.jose.proc.SecurityContext> keySource =
com.nimbusds.jose.jwk.source.JWKSourceBuilder
.create(new java.net.URL("https://appleid.apple.com/auth/keys"))
.retrying(true)
.build();
com.nimbusds.jwt.proc.DefaultJWTProcessor<com.nimbusds.jose.proc.SecurityContext> p =
new com.nimbusds.jwt.proc.DefaultJWTProcessor<>();
p.setJWSKeySelector(new com.nimbusds.jose.proc.JWSVerificationKeySelector<>(
com.nimbusds.jose.JWSAlgorithm.RS256, keySource));
appleJwtProcessor = p;
}
proc = appleJwtProcessor;
}
}
// 서명 검증(iss/aud/exp 호출부에서 확인)
return proc.process(idToken, null);
}
/**
* 토큰으로 세션을 조회하고, 유효하면 TTL 갱신(슬라이딩 만료)한다.
* Redis 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 재수화한다.
@@ -9,6 +9,7 @@ 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.ReportedItem;
import com.sb.web.board.dto.TagCategoryResponse;
import com.sb.web.board.dto.VoteRequest;
import com.sb.web.board.dto.VoteResponse;
@@ -223,4 +224,20 @@ public class BoardController {
boardService.unblockComment(commentId, current);
return ResponseEntity.noContent().build();
}
/** 신고된 글/댓글 목록 (관리자) — 대상별 신고 수 집계 */
@GetMapping("/reports")
public List<ReportedItem> reports(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return boardService.listReportedItems(current);
}
/** 신고 무시 (관리자) — 블라인드 없이 신고 기록만 초기화 */
@PostMapping("/reports/dismiss")
public ResponseEntity<Void> dismissReport(
@RequestBody Map<String, String> body,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
boardService.dismissReports(body.get("targetType"), Long.valueOf(body.get("targetId")), current);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,25 @@
package com.sb.web.board.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 신고 누적 항목 (관리자 신고 관리 화면). 대상별(/댓글) 신고 수를 집계.
*/
@Data
public class ReportedItem {
private String targetType; // POST / COMMENT
private Long targetId; // 대상(/댓글) id
private Long postId; // 링크용 id (POST=자기 자신, COMMENT=상위 )
private String category; // 상위 게시판(community/saving/tips)
private String title; // 제목 (댓글이면 상위 제목)
private String content; // 본문/댓글 내용 (앞부분 요약)
private Long authorId; // 작성자 id
private String authorName; // 작성자 표시명
private Boolean blocked; // 이미 블라인드 되었는지
private String blockReason; // 블라인드 사유 (글만 존재; 스팸 감지 / 신고 누적 )
private Integer reportCount; // 누적 신고
private LocalDateTime lastReportedAt; // 마지막 신고 시각
}
@@ -1,8 +1,11 @@
package com.sb.web.board.mapper;
import com.sb.web.board.dto.ReportedItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 신고(report) 매퍼 /댓글 신고 누적 집계.
*/
@@ -15,6 +18,9 @@ public interface ReportMapper {
@Param("memberId") Long memberId,
@Param("reason") String reason);
/** 신고된 글/댓글 목록 (대상별 집계 — 관리자 화면) */
List<ReportedItem> listReported();
int countByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
int deleteByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
@@ -11,6 +11,7 @@ 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.ReportedItem;
import com.sb.web.board.dto.VoteResponse;
import com.sb.web.board.mapper.CommentMapper;
import com.sb.web.board.mapper.PostMapper;
@@ -190,7 +191,11 @@ public class BoardService {
.build();
postMapper.insert(post);
applyTags(post.getId(), req.getTagIds());
pointService.awardBoardWrite(author.getId()); // 작성 보상( 3회 한도)
if (pointService.isSpam(req.getContent())) {
postMapper.updateBlocked(post.getId(), true, "스팸 키워드 감지");
} else {
pointService.awardBoardWrite(author.getId(), req.getContent());
}
return assemble(mustFindPost(post.getId()), author);
}
@@ -244,7 +249,11 @@ public class BoardService {
.content(req.getContent())
.build();
commentMapper.insert(comment);
pointService.awardBoardWrite(author.getId()); // 댓글 작성 보상( 3회 한도, 글과 합산)
if (pointService.isSpam(req.getContent())) {
commentMapper.updateBlocked(comment.getId(), true);
} else {
pointService.awardBoardWrite(author.getId(), req.getContent());
}
return CommentResponse.from(commentMapper.findById(comment.getId()));
}
@@ -293,6 +302,23 @@ public class BoardService {
}
}
/** 신고된 글/댓글 목록 (관리자 전용) — 대상별 신고 수 집계 */
@Transactional(readOnly = true)
public List<ReportedItem> listReportedItems(SessionUser user) {
assertAdmin(user);
return reportMapper.listReported();
}
/** 신고 무시 (관리자 전용) — 블라인드 없이 신고 기록만 초기화 */
@Transactional
public void dismissReports(String targetType, Long targetId, SessionUser user) {
assertAdmin(user);
if (!T_POST.equals(targetType) && !T_COMMENT.equals(targetType)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "잘못된 신고 대상 유형입니다.");
}
reportMapper.deleteByTarget(targetType, targetId);
}
/* ===================== 추천/비추천 ===================== */
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
@@ -310,6 +336,7 @@ public class BoardService {
} else {
voteMapper.upsertPostVote(postId, memberId, type);
}
pointService.applyVotePoints(post.getAuthorId(), memberId, existing, type);
return new VoteResponse(
voteMapper.countPostVotes(postId, UP),
voteMapper.countPostVotes(postId, DOWN),
@@ -334,6 +361,7 @@ public class BoardService {
} else {
voteMapper.upsertCommentVote(commentId, memberId, type);
}
pointService.applyVotePoints(comment.getAuthorId(), memberId, existing, type);
return new VoteResponse(
voteMapper.countCommentVotes(commentId, UP),
voteMapper.countCommentVotes(commentId, DOWN),
@@ -19,6 +19,7 @@ public class CorsConfig implements WebMvcConfigurer {
registry.addMapping("/api/**")
.allowedOrigins(
"http://localhost:5173", // Vue 개발 서버
"http://localhost:5174", // Vue 개발 서버 (포트 충돌 자동 증가)
"capacitor://localhost", // Capacitor (iOS scheme)
"https://localhost", // Capacitor Android (기본 https scheme)
"http://localhost", // Capacitor Android (http scheme)
@@ -41,7 +41,6 @@ public class WebConfig implements WebMvcConfigurer {
"/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/**",
@@ -5,9 +5,18 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 활동 포인트 적립/조회.
* 게시판 /댓글 작성 10점, 하루 3회까지만 지급(+댓글 합산).
*
* 정책 요약:
* - 게시글/댓글 작성: 10P, 5회(50P) 한도, 스팸 키워드 포함 미지급
* - 추천 받음( 작성자): +5P, 50P 한도
* - 비추천 받음( 작성자): -1P
* - 남의 추천(투표자): +1P, 10P 한도
* - 추천 취소(투표자): -1P
* - 가계부 내역 작성: 10P, 5회(50P) 한도
*/
@Service
@RequiredArgsConstructor
@@ -15,32 +24,156 @@ 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 int BOARD_WRITE_DAILY_LIMIT = 5; // 10P × 5 = 50P
public static final String REASON_BOARD_WRITE = "BOARD_WRITE";
// 추천 받음 ( 작성자)
public static final int POST_UPVOTED_POINT = 5;
public static final int POST_UPVOTED_DAILY_CAP = 10; // 5P × 10 = 50P
public static final String REASON_POST_UPVOTED = "POST_UPVOTED";
// 비추천 받음 ( 작성자)
public static final int POST_DOWNVOTED_POINT = -1;
public static final String REASON_POST_DOWNVOTED = "POST_DOWNVOTED";
// 남의 추천 (투표자)
public static final int VOTE_REWARD_POINT = 1;
public static final int VOTE_REWARD_DAILY_CAP = 10; // 1P × 10 = 10P
public static final String REASON_VOTE_REWARD = "VOTE_REWARD";
// 추천 취소 (투표자)
public static final int VOTE_CANCEL_POINT = -1;
public static final String REASON_VOTE_CANCEL = "VOTE_CANCEL";
// 가계부 내역 작성
public static final int ACCOUNT_ENTRY_POINT = 10;
public static final int ACCOUNT_ENTRY_DAILY_LIMIT = 5; // 10P × 5 = 50P
public static final String REASON_ACCOUNT_ENTRY = "ACCOUNT_ENTRY";
// 개근( 모든 날에 기록) 랜덤 1~100P, 1회. reason 연월 인코딩(MONTH_COMPLETE_yyyyMM)
public static final int MONTH_COMPLETE_MAX_POINT = 100;
public static final String REASON_MONTH_COMPLETE_PREFIX = "MONTH_COMPLETE_";
// 도배 방지 키워드 포함 작성 포인트 미지급
private static final List<String> SPAM_KEYWORDS = List.of(
"http://", "https://", "bit.ly", "goo.gl",
"무료지급", "무료쿠폰", "공짜", "홍보", "광고", "클릭하세요",
"카지노", "토토", "바카라", "도박"
);
public boolean isSpam(String content) {
if (content == null || content.isBlank()) return false;
String lower = content.toLowerCase();
return SPAM_KEYWORDS.stream().anyMatch(lower::contains);
}
/**
* 게시 /댓글 작성 보상. 오늘 지급 횟수가 한도 미만일 때만 지급한다.
* @return 지급되면 지급 포인트, 한도 초과로 미지급이면 0
* 게시글/댓글 작성 보상. 스팸 키워드 포함 또는 한도 초과 지급.
* @return 지급 포인트 (미지급이면 0)
*/
@Transactional
public int awardBoardWrite(Long memberId) {
public int awardBoardWrite(Long memberId, String content) {
if (isSpam(content)) return 0;
int today = pointMapper.countTodayGrants(memberId, REASON_BOARD_WRITE);
if (today >= BOARD_WRITE_DAILY_LIMIT) {
return 0;
}
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;
}
/**
* 투표 포인트 처리. 이전 투표(existing) 요청(newType) 비교해 적절한 포인트를 지급/차감한다.
*
* 케이스:
* - existing==newType (취소): UP 취소 투표자 -1P / DOWN 취소 변동 없음
* - UP: 작성자 +5P( 50P 한도), 투표자 +1P( 10P 한도)
* - DOWN: 작성자 -1P
* - UPDOWN 전환: 투표자 -1P(취소), 작성자 -1P(비추)
* - DOWNUP 전환: 작성자 +5P, 투표자 +1P
*/
@Transactional
public void applyVotePoints(Long authorId, Long voterId, String existing, String newType) {
boolean cancel = newType.equals(existing);
if (cancel) {
// 같은 타입 재요청 = 취소
if ("UP".equals(existing)) {
// 투표자가 받았던 +1P 회수
pointMapper.insertHistory(voterId, VOTE_CANCEL_POINT, REASON_VOTE_CANCEL);
pointMapper.addPoints(voterId, VOTE_CANCEL_POINT);
}
// DOWN 취소: 포인트 변동 없음
return;
}
// UPDOWN 전환: 투표자의 추천 보상 먼저 회수
if ("UP".equals(existing)) {
pointMapper.insertHistory(voterId, VOTE_CANCEL_POINT, REASON_VOTE_CANCEL);
pointMapper.addPoints(voterId, VOTE_CANCEL_POINT);
}
if ("UP".equals(newType)) {
// 작성자: +5P ( 한도)
if (pointMapper.countTodayGrants(authorId, REASON_POST_UPVOTED) < POST_UPVOTED_DAILY_CAP) {
pointMapper.insertHistory(authorId, POST_UPVOTED_POINT, REASON_POST_UPVOTED);
pointMapper.addPoints(authorId, POST_UPVOTED_POINT);
}
// 투표자: +1P ( 한도)
if (pointMapper.countTodayGrants(voterId, REASON_VOTE_REWARD) < VOTE_REWARD_DAILY_CAP) {
pointMapper.insertHistory(voterId, VOTE_REWARD_POINT, REASON_VOTE_REWARD);
pointMapper.addPoints(voterId, VOTE_REWARD_POINT);
}
} else if ("DOWN".equals(newType)) {
// 작성자: -1P
pointMapper.insertHistory(authorId, POST_DOWNVOTED_POINT, REASON_POST_DOWNVOTED);
pointMapper.addPoints(authorId, POST_DOWNVOTED_POINT);
}
}
/**
* 가계부 내역 작성 보상. 건당 10P, 하루 5건(50P)까지만 지급한다.
* @return 지급되면 지급 포인트, 한도 초과로 미지급이면 0
*/
@Transactional
public int awardAccountEntry(Long memberId) {
int today = pointMapper.countTodayGrants(memberId, REASON_ACCOUNT_ENTRY);
if (today >= ACCOUNT_ENTRY_DAILY_LIMIT) {
return 0;
}
pointMapper.insertHistory(memberId, ACCOUNT_ENTRY_POINT, REASON_ACCOUNT_ENTRY);
pointMapper.addPoints(memberId, ACCOUNT_ENTRY_POINT);
return ACCOUNT_ENTRY_POINT;
}
/**
* 달의 모든 날에 기록이 있을 주는 랜덤 보너스(1~100P). 1회만 지급한다.
* reason 연월을 인코딩(MONTH_COMPLETE_yyyyMM) 같은 중복 지급을 막는다.
* @return 지급 포인트(이미 지급됐으면 0)
*/
@Transactional
public int awardMonthComplete(Long memberId, int year, int month) {
String reason = REASON_MONTH_COMPLETE_PREFIX + String.format("%04d%02d", year, month);
if (pointMapper.countByReason(memberId, reason) > 0) return 0; // 이미 지급됨
int amount = java.util.concurrent.ThreadLocalRandom.current().nextInt(1, MONTH_COMPLETE_MAX_POINT + 1); // 1~100
pointMapper.insertHistory(memberId, amount, reason);
pointMapper.addPoints(memberId, amount);
return amount;
}
public long getPoints(Long memberId) {
Long p = pointMapper.getPoints(memberId);
return p == null ? 0L : p;
}
/** 최근 포인트 적립/차감 내역 (최신순) */
/** 최근 포인트 적립/차감 내역 (최신순, 최대 100건) */
public java.util.List<PointHistoryResponse> getHistory(Long memberId) {
return pointMapper.findHistory(memberId, 100);
}
/** 특정 연월 포인트 적립/차감 내역 (최신순, 전건) */
public java.util.List<PointHistoryResponse> getHistory(Long memberId, int year, int month) {
return pointMapper.findHistoryByMonth(memberId, year, month);
}
}
@@ -15,6 +15,9 @@ public interface PointMapper {
/** 오늘 특정 사유로 지급된 횟수 (일일 한도 계산용) */
int countTodayGrants(@Param("memberId") Long memberId, @Param("reason") String reason);
/** 특정 사유로 지금까지 지급된 총 횟수 (월 1회 등 전체기간 중복 방지용) */
int countByReason(@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);
@@ -23,4 +26,7 @@ public interface PointMapper {
/** 최근 적립/차감 내역 (최신순, 최대 limit건) */
List<PointHistoryResponse> findHistory(@Param("memberId") Long memberId, @Param("limit") int limit);
/** 특정 연월 적립/차감 내역 (최신순, 전건) */
List<PointHistoryResponse> findHistoryByMonth(@Param("memberId") Long memberId, @Param("year") int year, @Param("month") int month);
}
+13 -1
View File
@@ -20,9 +20,10 @@ spring:
max-request-size: 12MB
# ===== SQL 초기화 ===== 기동 시 member 테이블 자동 생성(IF NOT EXISTS 라 멱등)
# 로컬: never (원격 DB에 매번 DDL 실행 시 기동 지연). 운영: always (배포서버 환경변수로 덮어씀)
sql:
init:
mode: always
mode: ${SQL_INIT_MODE:never}
schema-locations: classpath:db/member.sql,classpath:db/board.sql,classpath:db/account.sql,classpath:db/dev-seed.sql
continue-on-error: true
@@ -78,9 +79,20 @@ app:
public-url: ${PUBLIC_URL:https://app.sblog.kr}
# 구글 OAuth 웹 클라이언트 ID (Google Cloud Console 발급). 미설정 시 구글 로그인 비활성.
google-client-id: ${GOOGLE_CLIENT_ID:}
# 애플 로그인 aud(=iOS 앱 번들 ID). 네이티브 Sign in with Apple 의 identity token 대상값.
apple-client-id: ${APPLE_CLIENT_ID:kr.sblog.slimbudget.app}
# AI 자연어 입력 파싱(Claude). 키 미설정 시 규칙기반 파서로 폴백(무중단). Haiku 권장(학습 미사용·저렴).
anthropic-api-key: ${ANTHROPIC_API_KEY:}
anthropic-model: ${ANTHROPIC_MODEL:claude-haiku-4-5}
# ===== Logging =====
logging:
file:
name: logs/app.log
logback:
rollingpolicy:
file-name-pattern: logs/app.%d{yyyy-MM-dd}.%i.log
max-history: 30
level:
root: INFO
com.sb.web: DEBUG
+30
View File
@@ -28,6 +28,12 @@ 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 '대출 실행 금액(원금, 처음 빌린 금액)';
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_payment BIGINT NULL COMMENT '월 상환금(고정, 원리금균등) — 은행 조회액과 맞추기 위한 선택 입력';
-- 계좌번호 암호화 저장(AES-GCM)으로 평문보다 길어 VARCHAR(255) 필요 — 운영 적용 완료(CREATE TABLE 정의에 반영).
-- 매 기동 MODIFY 는 라이브 테이블 락 위험이 있어 제거. 기존 환경 확장이 필요하면 1회 수동 적용:
-- ALTER TABLE wallet MODIFY account_number VARCHAR(255) NULL;
@@ -165,6 +171,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,
@@ -183,6 +198,21 @@ CREATE TABLE IF NOT EXISTS budget_income (
PRIMARY KEY (member_id, `year`, `month`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1회성 예산 (기간·제목별 단발 예산)
CREATE TABLE IF NOT EXISTS budget_onetime (
id BIGINT NOT NULL AUTO_INCREMENT,
member_id BIGINT NOT NULL COMMENT '소유자 member.id',
title VARCHAR(100) NOT NULL COMMENT '제목 (예: 7월 여행)',
category VARCHAR(50) NULL COMMENT '카테고리 (NULL=전체 지출)',
amount BIGINT NOT NULL COMMENT '총 예산',
start_date DATE NOT NULL COMMENT '기간 시작일',
end_date DATE NOT NULL COMMENT '기간 종료일',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_bot_member_date (member_id, start_date, end_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 투자 보유 종목 (INVEST 지갑별 · 사용자별)
CREATE TABLE IF NOT EXISTS invest_holding (
id BIGINT NOT NULL AUTO_INCREMENT,
+8
View File
@@ -27,6 +27,10 @@ ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_google_id (google_id);
-- 기존 구글 계정(provider=GOOGLE)의 sub 를 google_id 로 백필(멱등 — 이미 채워졌으면 아무 것도 안 함).
UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_id IS NULL AND provider_id IS NOT NULL;
-- 애플 로그인: 애플 sub 를 apple_id 로 저장(구글과 동일 — LOCAL 계정에 연결해도 provider 유지). 멱등.
ALTER TABLE member ADD COLUMN IF NOT EXISTS apple_id VARCHAR(100) NULL COMMENT '애플 sub (계정 연결용)' AFTER google_id;
ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_apple_id (apple_id);
-- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등).
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role;
@@ -61,6 +65,10 @@ CREATE TABLE IF NOT EXISTS iap_purchase (
KEY idx_iap_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 포인트 상점: 추가 계좌 슬롯(무료 회원 500P/개, 최대 3개), AI 통계 크레딧(1000P → 10회)
ALTER TABLE member ADD COLUMN IF NOT EXISTS extra_wallet_slots INT NOT NULL DEFAULT 0 COMMENT '포인트 상점 구매 추가 계좌 슬롯 수 (최대 3)' AFTER points;
ALTER TABLE member ADD COLUMN IF NOT EXISTS ai_stat_credits INT NOT NULL DEFAULT 0 COMMENT '포인트 상점 구매 AI 통계 크레딧 잔여 횟수' AFTER extra_wallet_slots;
-- 포인트 적립/차감 이력 (감사 + 일일 지급 한도 계산용).
CREATE TABLE IF NOT EXISTS point_history (
id BIGINT NOT NULL AUTO_INCREMENT,
@@ -234,4 +234,25 @@
ORDER BY t.name
</select>
<!-- 복수 항목의 태그를 한 번에 조회 — {entry_id, tag_name} 행 반환 -->
<select id="findTagsByEntryIds" resultType="map">
SELECT aet.entry_id AS entry_id, t.name AS tag_name
FROM account_tag t
JOIN account_entry_tag aet ON aet.tag_id = t.id
WHERE aet.entry_id IN
<foreach item="id" collection="entryIds" open="(" separator="," close=")">
#{id}
</foreach>
ORDER BY aet.entry_id, t.name
</select>
<!-- 특정 연·월에 기록이 있는 서로 다른 날짜 수(한 달 개근 판정) -->
<select id="countDistinctEntryDays" resultType="int">
SELECT COUNT(DISTINCT DAY(entry_date))
FROM account_entry
WHERE member_id = #{memberId}
AND YEAR(entry_date) = #{year}
AND MONTH(entry_date) = #{month}
</select>
</mapper>
@@ -19,6 +19,9 @@
<delete id="deleteBudgetIncome">
DELETE FROM budget_income WHERE member_id = #{memberId}
</delete>
<delete id="deleteBudgetOnetime">
DELETE FROM budget_onetime WHERE member_id = #{memberId}
</delete>
<delete id="deleteQuickEntries">
DELETE FROM quick_entry WHERE member_id = #{memberId}
</delete>
+24 -5
View File
@@ -14,15 +14,19 @@
<result property="weekly" column="weekly"/>
<result property="monthly" column="monthly"/>
<result property="yearly" column="yearly"/>
<result property="year" column="year"/>
<result property="month" column="month"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<sql id="cols">id, member_id, category, fixed, base_unit, base_amount,
daily, weekly, monthly, yearly, created_at, updated_at</sql>
daily, weekly, monthly, yearly, `year`, `month`, created_at, updated_at</sql>
<select id="findByMember" parameterType="long" resultMap="budgetResultMap">
SELECT <include refid="cols"/> FROM budget WHERE member_id = #{memberId} ORDER BY category
<select id="findByMember" resultMap="budgetResultMap">
SELECT <include refid="cols"/> FROM budget
WHERE member_id = #{memberId} AND `year` = #{year} AND `month` = #{month}
ORDER BY category
</select>
<select id="findByIdAndMember" resultMap="budgetResultMap">
@@ -32,15 +36,30 @@
<select id="countByCategory" resultType="int">
SELECT COUNT(*) FROM budget
WHERE member_id = #{memberId} AND category = #{category}
AND `year` = #{year} AND `month` = #{month}
<if test="excludeId != null">AND id != #{excludeId}</if>
</select>
<insert id="insert" parameterType="com.sb.web.account.domain.Budget"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO budget (member_id, category, fixed, base_unit, base_amount,
daily, weekly, monthly, yearly, created_at, updated_at)
daily, weekly, monthly, yearly, `year`, `month`, created_at, updated_at)
VALUES (#{memberId}, #{category}, #{fixed}, #{baseUnit}, #{baseAmount},
#{daily}, #{weekly}, #{monthly}, #{yearly}, NOW(), NOW())
#{daily}, #{weekly}, #{monthly}, #{yearly}, #{year}, #{month}, NOW(), NOW())
</insert>
<!-- 전월(또는 임의 월) 예산을 다른 월로 복사. 같은 분류는 덮어씀(ON DUPLICATE) -->
<insert id="copyMonth">
INSERT INTO budget (member_id, category, fixed, base_unit, base_amount,
daily, weekly, monthly, yearly, `year`, `month`, created_at, updated_at)
SELECT member_id, category, fixed, base_unit, base_amount,
daily, weekly, monthly, yearly, #{toYear}, #{toMonth}, NOW(), NOW()
FROM budget
WHERE member_id = #{memberId} AND `year` = #{fromYear} AND `month` = #{fromMonth}
ON DUPLICATE KEY UPDATE
fixed = VALUES(fixed), base_unit = VALUES(base_unit), base_amount = VALUES(base_amount),
daily = VALUES(daily), weekly = VALUES(weekly), monthly = VALUES(monthly),
yearly = VALUES(yearly), updated_at = NOW()
</insert>
<update id="update" parameterType="com.sb.web.account.domain.Budget">
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sb.web.account.mapper.BudgetOnetimeMapper">
<resultMap id="rm" type="com.sb.web.account.domain.BudgetOnetime">
<id property="id" column="id"/>
<result property="memberId" column="member_id"/>
<result property="title" column="title"/>
<result property="category" column="category"/>
<result property="amount" column="amount"/>
<result property="startDate" column="start_date"/>
<result property="endDate" column="end_date"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="findByMember" resultMap="rm">
SELECT id, member_id, title, category, amount, start_date, end_date, created_at, updated_at
FROM budget_onetime
WHERE member_id = #{memberId}
ORDER BY start_date DESC
</select>
<select id="findByIdAndMember" resultMap="rm">
SELECT id, member_id, title, category, amount, start_date, end_date, created_at, updated_at
FROM budget_onetime
WHERE id = #{id} AND member_id = #{memberId}
</select>
<!-- 기간 내 지출 합산 (category=null 이면 전체, 값 있으면 해당 카테고리만) -->
<select id="sumExpenseInRange" resultType="long">
SELECT COALESCE(SUM(amount), 0)
FROM account_entry
WHERE member_id = #{memberId}
AND type = 'EXPENSE'
AND entry_date BETWEEN #{startDate} AND #{endDate}
<if test="category != null and category != ''">
AND category = #{category}
</if>
</select>
<insert id="insert" parameterType="com.sb.web.account.domain.BudgetOnetime"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO budget_onetime (member_id, title, category, amount, start_date, end_date, created_at, updated_at)
VALUES (#{memberId}, #{title}, #{category}, #{amount}, #{startDate}, #{endDate}, NOW(), NOW())
</insert>
<update id="update" parameterType="com.sb.web.account.domain.BudgetOnetime">
UPDATE budget_onetime
SET title = #{title}, category = #{category}, amount = #{amount},
start_date = #{startDate}, end_date = #{endDate}, updated_at = NOW()
WHERE id = #{id} AND member_id = #{memberId}
</update>
<delete id="delete">
DELETE FROM budget_onetime WHERE id = #{id} AND member_id = #{memberId}
</delete>
</mapper>
+40 -3
View File
@@ -12,6 +12,7 @@
<result property="provider" column="provider"/>
<result property="providerId" column="provider_id"/>
<result property="googleId" column="google_id"/>
<result property="appleId" column="apple_id"/>
<result property="googlePicture" column="google_picture"/>
<result property="profileImage" column="profile_image"/>
<result property="role" column="role"/>
@@ -26,7 +27,7 @@
</resultMap>
<sql id="columns">
id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, plan_expires_at, plan_product, plan_auto_renew, points, status, created_at, updated_at
id, login_id, password, name, email, provider, provider_id, google_id, apple_id, google_picture, profile_image, role, plan, plan_expires_at, plan_product, plan_auto_renew, points, status, created_at, updated_at
</sql>
<select id="findById" parameterType="long" resultMap="memberResultMap">
@@ -68,15 +69,34 @@
UPDATE member SET google_id = #{googleId}, updated_at = NOW() WHERE id = #{id}
</update>
<!-- 애플 sub 로 회원 조회 (연결된 LOCAL 계정 + 순수 애플 계정 모두 apple_id 로 찾음) -->
<select id="findByAppleId" parameterType="string" resultMap="memberResultMap">
SELECT <include refid="columns"/> FROM member WHERE apple_id = #{appleId}
</select>
<!-- 애플 로그인 시 같은 이메일의 기존 계정 1건(연결 대상). LOCAL 계정 우선, 애플 미연결만. -->
<select id="findByEmailForAppleLink" parameterType="string" resultMap="memberResultMap">
SELECT <include refid="columns"/>
FROM member
WHERE email = #{email} AND status = 'ACTIVE' AND apple_id IS NULL
ORDER BY (provider = 'LOCAL') DESC, id ASC
LIMIT 1
</select>
<!-- 기존 계정에 애플 연결 (provider 는 그대로 유지) -->
<update id="linkApple">
UPDATE member SET apple_id = #{appleId}, updated_at = NOW() WHERE id = #{id}
</update>
<select id="countByLoginId" parameterType="string" resultType="int">
SELECT COUNT(*) FROM member WHERE login_id = #{loginId}
</select>
<insert id="insert" parameterType="com.sb.web.auth.domain.Member"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO member (login_id, password, name, email, provider, provider_id, google_id, google_picture, role, status, created_at, updated_at)
INSERT INTO member (login_id, password, name, email, provider, provider_id, google_id, apple_id, google_picture, role, status, created_at, updated_at)
VALUES (#{loginId}, #{password}, #{name}, #{email},
#{provider}, #{providerId}, #{googleId}, #{googlePicture}, #{role}, #{status}, NOW(), NOW())
#{provider}, #{providerId}, #{googleId}, #{appleId}, #{googlePicture}, #{role}, #{status}, NOW(), NOW())
</insert>
<update id="updatePassword">
@@ -132,4 +152,21 @@
DELETE FROM member WHERE id = #{id}
</delete>
<!-- 포인트 상점 -->
<select id="getExtraWalletSlots" resultType="int">
SELECT COALESCE(extra_wallet_slots, 0) FROM member WHERE id = #{id}
</select>
<select id="getAiStatCredits" resultType="int">
SELECT COALESCE(ai_stat_credits, 0) FROM member WHERE id = #{id}
</select>
<update id="incrementExtraWalletSlots">
UPDATE member SET extra_wallet_slots = extra_wallet_slots + 1, updated_at = NOW() WHERE id = #{id}
</update>
<update id="addAiStatCredits">
UPDATE member SET ai_stat_credits = ai_stat_credits + #{amount}, updated_at = NOW() WHERE id = #{id}
</update>
<update id="decrementAiStatCredit">
UPDATE member SET ai_stat_credits = GREATEST(0, ai_stat_credits - 1), updated_at = NOW() WHERE id = #{id}
</update>
</mapper>
+15 -1
View File
@@ -8,13 +8,18 @@
WHERE member_id = #{memberId} AND reason = #{reason} AND created_at &gt;= CURDATE()
</select>
<select id="countByReason" resultType="int">
SELECT COUNT(*) FROM point_history
WHERE member_id = #{memberId} AND reason = #{reason}
</select>
<insert id="insertHistory">
INSERT INTO point_history (member_id, amount, reason, created_at)
VALUES (#{memberId}, #{amount}, #{reason}, NOW())
</insert>
<update id="addPoints">
UPDATE member SET points = points + #{amount}, updated_at = NOW() WHERE id = #{memberId}
UPDATE member SET points = GREATEST(0, points + #{amount}), updated_at = NOW() WHERE id = #{memberId}
</update>
<select id="getPoints" parameterType="long" resultType="long">
@@ -29,4 +34,13 @@
LIMIT #{limit}
</select>
<select id="findHistoryByMonth" resultType="com.sb.web.point.PointHistoryResponse">
SELECT amount, reason, created_at
FROM point_history
WHERE member_id = #{memberId}
AND YEAR(created_at) = #{year}
AND MONTH(created_at) = #{month}
ORDER BY id DESC
</select>
</mapper>
@@ -78,4 +78,8 @@
UPDATE recurring SET last_run_date = #{lastRunDate} WHERE id = #{id}
</update>
<select id="countByMember" parameterType="long" resultType="int">
SELECT COUNT(*) FROM recurring WHERE member_id = #{memberId}
</select>
</mapper>
@@ -12,6 +12,33 @@
SELECT COUNT(*) FROM report WHERE target_type = #{targetType} AND target_id = #{targetId}
</select>
<!-- 신고/스팸 글·댓글 목록. 신고 1건 이상 OR 스팸 감지 블라인드 항목을 모두 포함. -->
<select id="listReported" resultType="com.sb.web.board.dto.ReportedItem">
SELECT 'POST' AS targetType, p.id AS targetId, p.id AS postId,
p.category AS category, p.title AS title, LEFT(p.content, 120) AS content,
p.author_id AS authorId, p.author_name AS authorName, p.blocked AS blocked,
p.block_reason AS blockReason,
COUNT(r.id) AS reportCount, MAX(r.created_at) AS lastReportedAt
FROM post p
LEFT JOIN report r ON r.target_id = p.id AND r.target_type = 'POST'
WHERE EXISTS (SELECT 1 FROM report WHERE target_type = 'POST' AND target_id = p.id)
OR p.block_reason = '스팸 키워드 감지'
GROUP BY p.id
UNION ALL
SELECT 'COMMENT' AS targetType, c.id AS targetId, c.post_id AS postId,
pp.category AS category, pp.title AS title, LEFT(c.content, 120) AS content,
c.author_id AS authorId, c.author_name AS authorName, c.blocked AS blocked,
NULL AS blockReason,
COUNT(r.id) AS reportCount, MAX(r.created_at) AS lastReportedAt
FROM comment c
JOIN post pp ON pp.id = c.post_id
LEFT JOIN report r ON r.target_id = c.id AND r.target_type = 'COMMENT'
WHERE EXISTS (SELECT 1 FROM report WHERE target_type = 'COMMENT' AND target_id = c.id)
OR c.blocked = 1
GROUP BY c.id
ORDER BY reportCount DESC, lastReportedAt DESC
</select>
<delete id="deleteByTarget">
DELETE FROM report WHERE target_type = #{targetType} AND target_id = #{targetId}
</delete>
+23 -4
View File
@@ -15,13 +15,21 @@
<result property="openingBalance" column="opening_balance"/>
<result property="openingDate" column="opening_date"/>
<result property="currentValue" column="current_value"/>
<result property="loanAmount" column="loan_amount"/>
<result property="loanRate" column="loan_rate"/>
<result property="loanMethod" column="loan_method"/>
<result property="loanMonths" column="loan_months"/>
<result property="loanStart" column="loan_start"/>
<result property="loanPayment" column="loan_payment"/>
<result property="sortOrder" column="sort_order"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<sql id="cols">id, member_id, type, name, issuer, account_number, card_type,
opening_balance, opening_date, current_value, sort_order, created_at, updated_at</sql>
opening_balance, opening_date, current_value,
loan_amount, loan_rate, loan_method, loan_months, loan_start, loan_payment,
sort_order, created_at, updated_at</sql>
<select id="findByMember" parameterType="long" resultMap="walletResultMap">
SELECT <include refid="cols"/> FROM wallet
@@ -37,10 +45,14 @@
<insert id="insert" parameterType="com.sb.web.account.domain.Wallet"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO wallet (member_id, type, name, issuer, account_number, card_type,
opening_balance, opening_date, current_value, sort_order, created_at, updated_at)
opening_balance, opening_date, current_value,
loan_amount, loan_rate, loan_method, loan_months, loan_start, loan_payment,
sort_order, created_at, updated_at)
VALUES (#{memberId}, #{type}, #{name}, #{issuer},
#{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler}, #{cardType},
#{openingBalance}, #{openingDate}, #{currentValue}, #{sortOrder}, NOW(), NOW())
#{openingBalance}, #{openingDate}, #{currentValue},
#{loanAmount}, #{loanRate}, #{loanMethod}, #{loanMonths}, #{loanStart}, #{loanPayment},
#{sortOrder}, NOW(), NOW())
</insert>
<update id="update" parameterType="com.sb.web.account.domain.Wallet">
@@ -49,7 +61,10 @@
account_number = #{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler},
card_type = #{cardType},
opening_balance = #{openingBalance}, opening_date = #{openingDate},
current_value = #{currentValue}
current_value = #{currentValue},
loan_amount = #{loanAmount},
loan_rate = #{loanRate}, loan_method = #{loanMethod},
loan_months = #{loanMonths}, loan_start = #{loanStart}, loan_payment = #{loanPayment}
WHERE id = #{id} AND member_id = #{memberId}
</update>
@@ -83,4 +98,8 @@
SELECT MAX(sort_order) FROM wallet WHERE member_id = #{memberId} AND type = #{type}
</select>
<select id="countByType" resultType="int">
SELECT COUNT(*) FROM wallet WHERE member_id = #{memberId} AND type = #{type}
</select>
</mapper>