Compare commits

110 Commits
Author SHA1 Message Date
isodevandClaude Opus 4.8 d201297463 docs: UGC 안전장치(가이드라인 1.2) 작업 요약 및 남은 작업 정리
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:42:03 +09:00
isodevandClaude Opus 4.8 0b027b3704 feat: 커뮤니티 UGC 안전장치 추가 (App Store 가이드라인 1.2 대응)
- 사용자 차단(user_block) 테이블·매퍼·API 추가
- 게시글/댓글 목록·상세 쿼리에 차단 사용자 필터링(즉시 숨김)
- 부적절 표현(욕설·비방) 콘텐츠 필터 추가(작성·수정·댓글 차단)
- 관리자용 사용자 차단 통지 조회 API

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:41:34 +09:00
sbandClaude Opus 4.8 b6976f5612 fix(auth): 애플 로그인 aud 기본값 application.yml → kr.sblog.slimbudget.app
- @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
sbandClaude Opus 4.8 4b23632314 chore(auth): 애플 로그인 aud 기본값 = iOS 번들ID kr.sblog.slimbudget.app
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:43:31 +09:00
ByungCheolandClaude Sonnet 4.6 6db6cb055e feat(shop): 포인트 상점 구현 — 계좌 슬롯(500P)·AI 통계 크레딧(1000P→10회)
- 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
ByungCheolandClaude Sonnet 4.6 75d5c9fa9b feat(point): 월별 포인트 내역 조회 API — year/month 파라미터 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 23:46:24 +09:00
ByungCheolandClaude Sonnet 4.6 22c492c1d2 fix(backup): budget_onetime 삭제 누락 추가 — deleteAll·restore 양쪽 모두 적용
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 23:10:03 +09:00
ByungCheolandClaude Sonnet 4.6 795afc80ff feat(account): 가계부 데이터 전체 삭제 API (DELETE /account/all)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 22:39:06 +09:00
sbandClaude Opus 4.8 ee644515cb fix(wallet): updateWallet 에서 loanPayment 저장 누락 수정
- 기존 대출 수정 시 월 상환금(loanPayment)이 저장되지 않던 문제
  (create 는 toWallet 로 반영됐으나 update 는 개별 setter 에서 누락)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:12:10 +09:00
sbandClaude Opus 4.8 ee2bc08b1e feat(wallet): 대출 월 상환금(loan_payment) 필드 추가
- 원리금균등 상환표를 은행 조회액과 정확히 맞추기 위한 고정 월 상환금 선택 저장
- 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
ByungCheolandClaude Sonnet 4.6 646d5f53fa fix(ai): 교통 분류 절약 팁 제외 — 정기권 최저비용 사용 중
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:02:51 +09:00
sbandClaude Opus 4.8 0780546d4f feat(recurring): 고정지출 무료 2건 허용(써보고 업셀) — 유료전용 해제
WebConfig에서 /recurrings 유료 게이트 제거, 무료는 생성 시 종류무관 2건 한도(초과 400).
유료·관리자 무제한, 복원은 한도 미적용. RecurringMapper.countByMember 추가.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:16:38 +09:00
sbandClaude Opus 4.8 29b14a5464 feat(wallet): 무료 회원 계좌 종류별 2개 한도 (유료 무제한)
- 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
sbandClaude Opus 4.8 cc36f9e939 revert(point): 개근 보너스 전월 제한 복구 — 현재 월만 인정
테스트(6월 검증) 완료 후 원래 규칙으로 복구. 전월 소급 파밍 방지.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:01:00 +09:00
sbandClaude Opus 4.8 45bb025247 feat(point): 한 달 개근(모든 날 기록) 랜덤 보너스 1~100P
내역 저장 시 현재 월 모든 날에 기록이 있으면 월 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
ByungCheolandClaude Sonnet 4.6 a0509099f7 feat(budget): 1회성 예산 API 추가
- 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
sbandClaude Opus 4.8 24858f23e8 style(ai): 재무 코멘트·절약가이드 말투를 정중한 종결어미로 통일
'~입니다/~합니다/~할 수 있습니다' 합쇼체 강제, 반말·음슴체 금지 + 예시 추가.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:13:09 +09:00
sbandClaude Opus 4.8 4aeac2ac0f feat(ai): 통계 화면 AI 재무 코멘트
이번 달 집계(수입·지출·전월대비·예산·분류별)만 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
ByungCheolandClaude Sonnet 4.6 655af7af63 feat(fx): 환율 캐시 시각 노출 — updatedAt 응답 추가
- 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
sbandClaude Opus 4.8 c5f16795ef fix(ai): 영수증 AI 멀티파트 파트수 초과(500) 수정
Tomcat 기본 파트 수 제한(10)에 categoryHints/walletHints를 이름마다 파트로 보내 초과 → 500.
힌트를 줄바꿈으로 합친 단일 필드로 받아 파트 3개로 축소.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:46:14 +09:00
sbandClaude Opus 4.8 28670b69a1 feat(ai): 영수증 사진 → Claude Vision 구조화
/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
sbandClaude Opus 4.8 53326f089b feat(ai): 자연어 입력 시 분류·결제수단 자동 추천
사용자의 실제 카테고리·계좌 후보를 AI에 전달해 그 중에서 선택(할루시네이션 방지 검증).
라면→식비, 삼성카드→해당 계좌 등 폼 자동 채움 확장.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:47:52 +09:00
ByungCheolandClaude Sonnet 4.6 82eea0b908 feat(board): 스팸 키워드 자동 블라인드 및 관리자 신고 목록 노출
- 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
sbandClaude Opus 4.8 b3b42f0db6 fix(ai): 모델이 설명문 섞어 반환해도 JSON만 추출 — 파싱 견고성
첫 '{'~마지막 '}' 구간만 추출해 파싱, 중괄호 없으면 조용히 규칙 폴백(WARN 대신 INFO)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:57:50 +09:00
ByungCheol 32da57e0e0 feat(point): 게시판 포인트 정책 개편
작성: 10P, 일 50P 한도(5회), 스팸 키워드 포함 시 미지급
추천 받음(작성자): +5P, 일 50P 한도
비추천 받음(작성자): -1P
남의 글 추천(투표자): +1P, 일 10P 한도
추천 취소(투표자): -1P
addPoints에 GREATEST(0,...) 적용 — 잔액 음수 방지
2026-07-05 12:38:40 +09:00
sbandClaude Opus 4.8 d65dd88bba chore(ai): 파싱 전과정 진단 로그(요청텍스트·모델원문·결과) 추가
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:36:40 +09:00
sbandClaude Opus 4.8 30cb68c003 chore(ai): 시작 시 AI 파서 활성/키로딩 진단 로그 추가
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:12:04 +09:00
sbandClaude Opus 4.8 811cc1b391 feat(ai): 자연어 한 줄 입력 파싱 — Claude Haiku 프록시(키 서버보관·실패 시 규칙 폴백)
- 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
sbandClaude Opus 4.8 0082e647c2 ci: setup-java 캐시 제거 — 'Save cache' 후처리 hang 회피
Gitea 캐시 서버 미연결 시 cache:gradle 저장 후처리가 수 분 hang → 가짜 실패 유발.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:56:10 +09:00
sbandClaude Opus 4.8 9b5abedd40 ci(deploy): 배포 잡에서 테스트/서비스 제거 — bootJar 만 빌드
서비스 컨테이너(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
sbandClaude Opus 4.8 9fdec36758 fix(ci): SQL_INIT_MODE=always 추가 — 컨텍스트 테스트 스키마 초기화
@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 한도
AccountService.create() 호출 시 PointService.awardAccountEntry() 적립.
ACCOUNT_ENTRY reason, 일 5회(50p) 초과 시 미지급.
2026-07-04 23:14:46 +09:00
sbandClaude Opus 4.8 a10e16c1af feat(auth): 애플 로그인 API — Sign in with Apple identity token 검증(JWKS)
- 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
sbandClaude Opus 4.8 2f376e6f77 feat(board): 신고 목록 조회 API + 신고 무시(관리자)
- 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
sbandClaude Opus 4.8 11d1655261 docs: BACKEND.md 현행화(신규 API·스키마·투자 모델·구글로그인)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:11:58 +09:00
ByungCheolandClaude Sonnet 4.6 8ced77480e fix(cors): localhost:5174 허용 추가 — Vite 포트 충돌 시 자동 증가 대응
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:45:14 +09:00
ByungCheolandClaude Sonnet 4.6 9ed80f7085 fix(config): 로컬 기동 시 SQL 초기화 스킵(never) — 운영은 SQL_INIT_MODE=always 환경변수로 제어
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:07:24 +09:00
ByungCheolandClaude Sonnet 4.6 9b04ad3310 fix(logging): 로그 파일 경로를 상대경로(logs/)로 변경 — 절대경로 디렉토리 미존재 문제 해결
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:49:02 +09:00
ByungCheolandClaude Sonnet 4.6 f62889ac62 chore: 일별 롤링 로그 추가 (/opt/sb-backend/logs/app.YYYY-MM-DD.log, 30일 보관)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:25:46 +09:00
ByungCheolandClaude Sonnet 4.6 511843f1b6 fix(account): findTagsByEntryIds Map key 소문자로 통일 + null guard
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' 2026-07-02 01:47:29 +09:00
ByungCheolandClaude Sonnet 4.6 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' 2026-07-02 00:45:44 +09:00
ByungCheol 186aec7bb4 Merge branch 'dev' 2026-06-30 22:35:35 +09:00
ByungCheol 3535c2e220 Merge branch 'dev' 2026-06-30 21:37:07 +09:00
ByungCheol 204c5ce430 Merge branch 'dev' 2026-06-28 18:25:50 +09:00
ByungCheol 706deb057e Merge branch 'dev' 2026-06-28 18:16:58 +09:00
ByungCheol e5d1b5d40c Merge branch 'dev' 2026-06-28 18:05:13 +09:00
ByungCheol d028d957b6 Merge branch 'dev' 2026-06-28 17:29:17 +09:00
ByungCheol f28ecd853e Merge branch 'dev' 2026-06-28 16:56:25 +09:00
ByungCheol 5fec464f90 Merge branch 'dev' 2026-06-28 16:04:22 +09:00
ByungCheol 483aaa6473 Merge branch 'dev' 2026-06-28 15:44:26 +09:00
ByungCheol 5bd2616de9 Merge branch 'dev' 2026-06-28 15:24:13 +09:00
ByungCheol ad40af18b2 Merge branch 'dev' 2026-06-28 15:12:47 +09:00
ByungCheol 95d84c8138 Merge branch 'dev' 2026-06-28 14:44:25 +09:00
ByungCheol 08338d516f Merge branch 'dev' 2026-06-28 14:05:01 +09:00
ByungCheol dc4e99a6db Merge branch 'dev' 2026-06-28 13:34:21 +09:00
ByungCheol f408715e17 Merge branch 'dev' 2026-06-28 13:21:21 +09:00
ByungCheol 74a2cae9ef Merge branch 'dev' 2026-06-28 12:29:27 +09:00
ByungCheol 6e269888c2 Merge branch 'dev' 2026-06-28 11:07:51 +09:00
ByungCheol 39a11d299c Merge branch 'dev' 2026-06-28 09:22:46 +09:00
ByungCheol f19f653715 Merge branch 'dev' 2026-06-28 09:04:22 +09:00
ByungCheol 16ddf88de3 Merge branch 'dev' 2026-06-28 08:38:52 +09:00
ByungCheol d794549f66 Merge branch 'dev' 2026-06-27 23:00:14 +09:00
ByungCheol 0e26237577 Merge branch 'dev' 2026-06-27 22:27:13 +09:00
ByungCheol 1646ec0a7f Merge branch 'dev' 2026-06-27 19:02:12 +09:00
ByungCheol fe0837cfd6 Merge branch 'dev' 2026-06-27 18:03:44 +09:00
ByungCheol 68f6c985bc Merge branch 'dev' 2026-06-27 14:47:00 +09:00
ByungCheol 47a01356a5 Merge branch 'dev' 2026-06-24 23:03:38 +09:00
ByungCheol 236332637a Merge branch 'dev' 2026-06-22 23:16:03 +09:00
ByungCheol 0e71e7b8cc Merge branch 'dev' 2026-06-22 22:38:06 +09:00
ByungCheol 9d4a415f2e Merge branch 'dev' 2026-06-20 17:13:14 +09:00
ByungCheol 5230ba1587 Merge branch 'dev' 2026-06-14 15:43:30 +09:00
ByungCheol 1f8b968588 Merge branch 'dev' 2026-06-12 22:52:23 +09:00
ByungCheol b90f9ea3b8 Merge branch 'dev' 2026-06-12 22:22:59 +09:00
ByungCheol a61700bdf6 Merge branch 'dev' 2026-06-12 22:16:09 +09:00
ByungCheol 484732c5ed Merge branch 'dev' 2026-06-09 22:47:32 +09:00
ByungCheol e3a24848bc Merge branch 'dev' 2026-06-07 20:32:38 +09:00
ByungCheol fd25968d0e Merge branch 'dev' 2026-06-07 19:17:25 +09:00
ByungCheol c61cec6dad Merge branch 'dev' 2026-06-06 17:33:33 +09:00
ByungCheol 2eef5c3b3e Merge branch 'dev' 2026-06-06 16:42:34 +09:00
ByungCheol 4f8ade5ff3 Merge branch 'dev' 2026-06-06 15:03:53 +09:00
ByungCheol 8bfe596f4e Merge branch 'dev' 2026-06-06 14:49:48 +09:00
ByungCheol 76bbf195df Merge branch 'dev' 2026-06-06 14:27:33 +09:00
ByungCheol b62b51b789 Merge branch 'dev' 2026-06-06 14:14:15 +09:00
ByungCheol 91cc5558e4 Merge branch 'dev' 2026-06-06 14:08:13 +09:00
ByungCheol 27b8829def Merge branch 'dev' 2026-06-06 08:13:03 +09:00
ByungCheol ad2ee4e75d Merge branch 'dev' 2026-06-06 00:16:30 +09:00
ByungCheol eb24a315d3 Merge branch 'dev' 2026-06-04 05:00:27 +09:00
ByungCheol 396e986792 Merge branch 'dev' 2026-06-03 18:51:32 +09:00
ByungCheol 191f1cfbe4 Merge branch 'dev' 2026-06-03 17:37:20 +09:00
ByungCheol 1d4dbc2183 Merge branch 'dev' 2026-06-03 16:42:22 +09:00
ByungCheol 478a75b6d8 Merge branch 'dev' into main 2026-06-01 22:54:25 +09:00
ByungCheol 41a5780e14 Merge branch 'dev' into main 2026-06-01 22:37:43 +09:00
ByungCheol 86c56f6c6f Merge branch 'dev' into main 2026-05-31 21:46:30 +09:00
ByungCheol bd1164866c Merge branch 'dev' into main 2026-05-31 20:49:52 +09:00
ByungCheol f884cb7b06 Merge branch 'dev' into main 2026-05-31 19:12:56 +09:00
ByungCheol e5fd31843a Merge branch 'dev' into main 2026-05-31 19:01:43 +09:00
ByungCheol a821cd8f89 Merge branch 'dev' into main 2026-05-31 18:46:44 +09:00
ByungCheol cf275fabf6 Merge branch 'dev' into main 2026-05-31 18:22:53 +09:00
ByungCheol d373e3e9e4 Merge branch 'dev' into main
fix(ci): SSH 개인키 CR 제거
2026-05-31 17:40:10 +09:00
ByungCheol f79c2412fd Merge branch 'dev' into main
ci: 자동 배포 워크플로 + 서버 설정 파일
2026-05-31 15:55:35 +09:00
ByungCheol f3230dcc5b Merge branch 'dev' into main
ci: Gitea Actions CI/CD 워크플로 추가
2026-05-31 15:49:08 +09:00
ByungCheol ef7d0f7f83 Merge branch 'dev' into main
가계부·게시판 백엔드 API 구현 (account/auth/board/user/admin)
2026-05-31 15:45:25 +09:00
70 changed files with 1975 additions and 127 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'
+60 -37
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 /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)
+63
View File
@@ -0,0 +1,63 @@
# App Store 심사 대응 — 커뮤니티 UGC 안전장치 (Guideline 1.2)
작성일: 2026-07-15
관련 커밋: sb-backend `0b027b3`, sb-front `9b89d0b`
## 배경 — 애플 심사 거부 내역
첫 제출(1.0) 심사에서 아래 사유로 거부됨.
1. **Guideline 2.3.6 (Accurate Metadata)** — 연령 등급의 "In-App Controls"(Parental Controls / Age Assurance)를 선택했으나 앱에 해당 기능 없음
→ App Store Connect 연령 등급에서 두 항목 **None** 으로 변경 (코드 아님, 콘솔 작업)
2. **Guideline 2.1(a) (Information Needed)** — 데모 계정 + 커뮤니티/짠테크 수다방/재테크 팁에 콘텐츠 필요
→ 데모 계정 등록 + 샘플 콘텐츠 채우기 (콘솔/데이터 작업)
3. **Guideline 1.2 (User-Generated Content)** — UGC 안전장치 미비 ← **이번 코드 작업 대상**
## Guideline 1.2 요구사항 대비 구현 현황
| 요구사항 | 상태 | 구현 |
|----------|------|------|
| EULA 동의(가입/로그인 전) | 기존+보강 | 가입 모달 동의 + **로그인 모달 동의 게이트 신설** |
| EULA 내 "무관용" 문구 | 신규 | 이용약관 제6조의2 추가 |
| 부적절 콘텐츠 필터 | 신규 | `ContentFilter` (작성/수정/댓글 차단) |
| 콘텐츠 신고(flag) | 기존 | `reportPost/reportComment` (5건 누적 블라인드) |
| 악성 사용자 차단(block) | 신규 | `user_block` — 차단 즉시 숨김 + 운영자 통지 |
| 24h 내 삭제/퇴출 | 기존 | 관리자 신고관리 + 회원 상태(이용정지/퇴출) |
## 변경 파일
### 백엔드 (sb-backend / 로컬 `sb_bt`)
- `db/board.sql` — `user_block` 테이블 추가 (`CREATE TABLE IF NOT EXISTS`, 멱등)
- `board/service/ContentFilter.java` (신규) — 욕설·비방 등 금지어 필터, 정규화 후 부분일치
- `board/dto/BlockedUser.java`, `board/dto/UserBlockNotice.java` (신규)
- `board/mapper/UserBlockMapper.java` + `mapper/UserBlockMapper.xml` (신규) — 차단 CRUD/조회
- `board/service/BoardService.java` — 차단 메서드, 목록/상세/댓글에 viewer 기준 차단 필터, ContentFilter 연동
- `board/controller/BoardController.java` — 차단 API (`/board/users/{id}/block|unblock`, `/board/blocks`, `/board/user-blocks`)
- `board/mapper/PostMapper.*`, `CommentMapper.*` — 목록/댓글 쿼리에 `NOT EXISTS user_block` 필터 (viewerId)
### 프론트 (sb-front)
- `constants/terms.js` — 무관용 정책 조항(제6조의2), 시행일 2026-07-15
- `components/LoginModal.vue` — 로그인 전 약관 동의 체크박스(아이디·구글·애플 공통 게이트)
- `api/boardApi.js` — 차단 API 4종
- `views/board/BoardDetailView.vue` — 글·댓글 🚫차단 버튼 + 로직
- `views/settings/BlockedUsersView.vue` (신규) + `router/index.js` — 차단 사용자 관리 화면 `/settings/blocked`
- `views/settings/SettingsView.vue` — "차단한 사용자" 메뉴 링크
- `views/admin/ReportAdminView.vue` — 사용자 차단 통지 섹션
## 배포 메모
- 백엔드 프로덕션은 `SQL_INIT_MODE=always` → 재기동 시 `board.sql`의 `user_block` 자동 생성(멱등).
- `main` 푸시 시 Gitea Actions 자동 배포.
## ⏳ 미해결 / 남은 작업 (다음 세션)
1. **배포 SSH 인증 실패** — Deploy 워크플로가 `Permission denied (publickey,password)`로 실패 중.
- root + 공개키 서버 등록됨, **로컬 `ssh -i ~/deploy_key root@115.68.231.167` 접속은 성공**.
- 새 ed25519 키 발급 → 서버 authorized_keys 등록 → Gitea `DEPLOY_SSH_KEY` 시크릿 교체했으나 **재실행 여전히 동일 실패**.
- 추정: 러너가 받는 시크릿 값이 실제 키와 다름(저장 안 됨 / org-level 시크릿 shadowing 등).
- 다음: 러너가 쥔 개인키 **지문(fingerprint) 확인**으로 확정 → 로컬 `ssh-keygen -lf ~/deploy_key` 와 비교. org/repo 시크릿 위치 점검.
2. **iOS 재제출**
- `cd sb-front && npm run cap:sync` → Xcode 빌드 번호 **1.0(3)** 올려 업로드
- 실기기 **화면 녹화**: ①로그인 전 약관 동의 ②신고 ③차단
- App Review Information Notes 에 영상 + 데모 계정(콘텐츠 포함) 첨부
- 연령 등급 In-App Controls → **None** 확인
3. **App Store Connect** — 커뮤니티/짠테크 수다방/재테크 팁 샘플 콘텐츠 채우기, 데모 계정 로그인 검증
@@ -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;
/** 월 예상 수입 조회 (연·월별) */
@@ -110,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");
};
}
}
@@ -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;
}
@@ -35,6 +35,7 @@ public class Wallet implements Serializable {
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;
}
@@ -17,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 = "이름을 입력하세요.")
@@ -53,4 +53,5 @@ public class WalletRequest {
private Integer loanMonths; // 대출기간(개월)
private LocalDate loanStart; // 대출시작일
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 선택
}
@@ -38,6 +38,7 @@ public class WalletResponse {
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()
@@ -56,6 +57,7 @@ public class WalletResponse {
.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);
@@ -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,7 +622,7 @@ 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());
@@ -541,6 +633,7 @@ public class AccountService {
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()));
}
@@ -575,7 +668,7 @@ public class AccountService {
.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())
@@ -585,6 +678,7 @@ public class AccountService {
.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,7 +171,8 @@ 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);
}
}
@@ -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();
}
}
@@ -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;
@@ -49,8 +50,9 @@ public class BoardController {
@RequestParam(required = false) String category,
@RequestParam(required = false) String tag,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String searchType) {
return boardService.list(page, size, category, tag, keyword, searchType);
@RequestParam(required = false) String searchType,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return boardService.list(page, size, category, tag, keyword, searchType, current);
}
/** 내 글 모아보기 */
@@ -223,4 +225,55 @@ 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();
}
/* ===================== 사용자 차단 (UGC 1.2) ===================== */
/** 사용자 차단 — 차단 즉시 상대 콘텐츠가 내 화면에서 숨겨진다 */
@PostMapping("/users/{id}/block")
public ResponseEntity<Void> blockUser(
@PathVariable Long id,
@RequestBody(required = false) Map<String, String> body,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
boardService.blockUser(id, body != null ? body.get("reason") : null, current);
return ResponseEntity.noContent().build();
}
/** 사용자 차단 해제 */
@PostMapping("/users/{id}/unblock")
public ResponseEntity<Void> unblockUser(
@PathVariable Long id,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
boardService.unblockUser(id, current);
return ResponseEntity.noContent().build();
}
/** 내가 차단한 사용자 목록 (관리 화면) */
@GetMapping("/blocks")
public List<com.sb.web.board.dto.BlockedUser> myBlocks(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return boardService.myBlockedUsers(current);
}
/** 최근 사용자 차단 통지 (관리자) — 운영자 검토용 */
@GetMapping("/user-blocks")
public List<com.sb.web.board.dto.UserBlockNotice> userBlocks(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return boardService.listUserBlocks(current);
}
}
@@ -0,0 +1,18 @@
package com.sb.web.board.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 내가 차단한 사용자 항목 (차단 목록 관리 화면용).
*/
@Data
public class BlockedUser {
private Long blockedId;
private String name;
private String googlePicture;
private String profileImage;
private String reason;
private LocalDateTime createdAt;
}
@@ -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; // 마지막 신고 시각
}
@@ -0,0 +1,19 @@
package com.sb.web.board.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 사용자 차단 통지 항목 (관리자 화면용).
* 어떤 회원이 어떤 회원을 왜 차단했는지 — 운영자가 24시간 내 검토·조치할 수 있도록 노출.
*/
@Data
public class UserBlockNotice {
private Long blockerId;
private String blockerName;
private Long blockedId;
private String blockedName;
private String reason;
private LocalDateTime createdAt;
}
@@ -9,7 +9,7 @@ import java.util.List;
@Mapper
public interface CommentMapper {
List<Comment> findByPostId(@Param("postId") Long postId);
List<Comment> findByPostId(@Param("postId") Long postId, @Param("viewerId") Long viewerId);
Comment findById(@Param("id") Long id);
@@ -15,18 +15,21 @@ public interface PostMapper {
@Param("category") String category,
@Param("tag") String tag,
@Param("keyword") String keyword,
@Param("searchType") String searchType);
@Param("searchType") String searchType,
@Param("viewerId") Long viewerId);
long countPage(@Param("category") String category,
@Param("tag") String tag,
@Param("keyword") String keyword,
@Param("searchType") String searchType);
@Param("searchType") String searchType,
@Param("viewerId") Long viewerId);
/** 인기 글(추천 minUp 이상 & hours 시간 이내) 상단 노출용 — 추천 많은 순 limit 건 */
List<PostSummary> findHotPosts(@Param("category") String category,
@Param("hours") int hours,
@Param("minUp") int minUp,
@Param("limit") int limit);
@Param("limit") int limit,
@Param("viewerId") Long viewerId);
/** 내 글 모아보기 */
List<PostSummary> findMyPage(@Param("memberId") Long memberId,
@@ -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);
@@ -0,0 +1,36 @@
package com.sb.web.board.mapper;
import com.sb.web.board.dto.BlockedUser;
import com.sb.web.board.dto.UserBlockNotice;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 사용자 차단(user_block) 매퍼 — 회원 간 차단 관계.
*/
@Mapper
public interface UserBlockMapper {
/** 차단 추가 (중복은 무시, 재차단 시 사유 갱신) */
int upsert(@Param("blockerId") Long blockerId,
@Param("blockedId") Long blockedId,
@Param("reason") String reason);
/** 차단 해제 */
int delete(@Param("blockerId") Long blockerId,
@Param("blockedId") Long blockedId);
/** 내가 차단한 회원 id 목록 (피드 필터용) */
List<Long> findBlockedIds(@Param("blockerId") Long blockerId);
/** 내가 차단한 회원 목록 (관리 화면용 — 이름/아바타 포함) */
List<BlockedUser> findBlockedUsers(@Param("blockerId") Long blockerId);
/** 최근 차단 통지 목록 (관리자 — 운영자 검토용) */
List<UserBlockNotice> listRecent(@Param("limit") int limit);
/** 회원 탈퇴 시 관련 차단 관계 정리 */
int deleteByMember(@Param("memberId") Long memberId);
}
@@ -11,11 +11,15 @@ 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.dto.BlockedUser;
import com.sb.web.board.dto.UserBlockNotice;
import com.sb.web.board.mapper.CommentMapper;
import com.sb.web.board.mapper.PostMapper;
import com.sb.web.board.mapper.ReportMapper;
import com.sb.web.board.mapper.TagMapper;
import com.sb.web.board.mapper.UserBlockMapper;
import com.sb.web.board.mapper.VoteMapper;
import com.sb.web.common.exception.ApiException;
import com.sb.web.point.PointService;
@@ -43,7 +47,9 @@ public class BoardService {
private final CommentMapper commentMapper;
private final VoteMapper voteMapper;
private final ReportMapper reportMapper;
private final UserBlockMapper userBlockMapper;
private final PointService pointService;
private final ContentFilter contentFilter;
private final RedisTemplate<String, Object> redisTemplate;
/** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */
@@ -62,20 +68,21 @@ public class BoardService {
/* ===================== 게시글 ===================== */
public PageResponse<PostSummary> list(int page, int size, String category, String tag, String keyword, String searchType) {
public PageResponse<PostSummary> list(int page, int size, String category, String tag, String keyword, String searchType, SessionUser viewer) {
int p = Math.max(page, 1);
int s = Math.min(Math.max(size, 1), 100);
int offset = (p - 1) * s;
String st = normalizeSearchType(searchType);
String cat = normalizeCategory(category);
Long viewerId = viewer == null ? null : viewer.getId();
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
List<PostSummary> content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st);
long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st);
List<PostSummary> content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st, viewerId);
long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st, viewerId);
// 1페이지·검색 없을 때만 인기 글을 최상단에 노출 (중복 제거)
if (p == 1 && blankToNull(tag) == null && blankToNull(keyword) == null) {
List<PostSummary> hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX);
List<PostSummary> hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX, viewerId);
if (!hot.isEmpty()) {
java.util.Set<Long> hotIds = new java.util.HashSet<>();
hot.forEach(h -> { h.setHot(true); hotIds.add(h.getId()); });
@@ -179,6 +186,9 @@ public class BoardService {
@Transactional
public PostDetail create(PostRequest req, SessionUser author) {
// 무관용 콘텐츠 필터 — 욕설·비방 등 부적절 표현 포함 시 등록 차단
contentFilter.check(req.getTitle());
contentFilter.check(req.getContent());
Post post = Post.builder()
.title(req.getTitle())
.content(req.getContent())
@@ -190,7 +200,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);
}
@@ -202,6 +216,10 @@ public class BoardService {
}
assertCanModify(post.getAuthorId(), user);
// 무관용 콘텐츠 필터 — 수정 시에도 부적절 표현 차단
contentFilter.check(req.getTitle());
contentFilter.check(req.getContent());
post.setTitle(req.getTitle());
post.setContent(req.getContent());
postMapper.update(post);
@@ -237,6 +255,8 @@ public class BoardService {
if (Boolean.TRUE.equals(post.getBlocked())) {
throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글에는 댓글을 작성할 수 없습니다.");
}
// 무관용 콘텐츠 필터 — 부적절 표현 포함 댓글 등록 차단
contentFilter.check(req.getContent());
Comment comment = Comment.builder()
.postId(postId)
.authorId(author.getId())
@@ -244,7 +264,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 +317,56 @@ 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);
}
/* ===================== 사용자 차단 ===================== */
/**
* 사용자 차단 (App Store 1.2 UGC). 차단 즉시 상대 콘텐츠가 내 목록·상세·댓글에서 숨겨진다.
* reason 은 운영자 통지(관리자 차단 목록)에 사용된다.
*/
@Transactional
public void blockUser(Long blockedId, String reason, SessionUser user) {
if (blockedId == null || blockedId.equals(user.getId())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "자기 자신은 차단할 수 없습니다.");
}
userBlockMapper.upsert(user.getId(), blockedId, blankToNull(reason));
}
/** 차단 해제 */
@Transactional
public void unblockUser(Long blockedId, SessionUser user) {
userBlockMapper.delete(user.getId(), blockedId);
}
/** 내가 차단한 사용자 목록 (관리 화면용) */
@Transactional(readOnly = true)
public List<BlockedUser> myBlockedUsers(SessionUser user) {
return userBlockMapper.findBlockedUsers(user.getId());
}
/** 최근 사용자 차단 통지 (관리자 전용) — 운영자 24시간 내 검토용 */
@Transactional(readOnly = true)
public List<UserBlockNotice> listUserBlocks(SessionUser user) {
assertAdmin(user);
return userBlockMapper.listRecent(200);
}
/* ===================== 추천/비추천 ===================== */
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
@@ -310,6 +384,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 +409,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),
@@ -364,7 +440,7 @@ public class BoardService {
for (CommentVoteSummary s : voteMapper.findCommentVoteSummary(postId, viewerId)) {
voteByComment.put(s.getCommentId(), s);
}
List<CommentResponse> comments = new java.util.ArrayList<>(commentMapper.findByPostId(postId).stream().map(c -> {
List<CommentResponse> comments = new java.util.ArrayList<>(commentMapper.findByPostId(postId, viewerId).stream().map(c -> {
CommentResponse cr = CommentResponse.from(c);
CommentVoteSummary s = voteByComment.get(c.getId());
if (s != null) {
@@ -0,0 +1,56 @@
package com.sb.web.board.service;
import com.sb.web.common.exception.ApiException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import java.text.Normalizer;
import java.util.List;
/**
* 커뮤니티 콘텐츠 필터 (App Store 가이드라인 1.2 UGC — "부적절한 콘텐츠 필터링" 요건).
*
* 게시글·댓글 작성 시 욕설/혐오/차별 등 불쾌한 표현이 포함되면 등록을 차단한다.
* (스팸/광고 도배는 별도로 PointService.isSpam 에서 블라인드 처리)
*
* 비교 전 정규화: 소문자화 + 공백/특수문자 제거로 "시 발", "시_발" 등 우회를 일부 차단.
*/
@Component
public class ContentFilter {
// 무관용 정책 대상 금지어(대표 예시 — 필요 시 확장). 소문자·정규화 후 부분일치로 검사.
private static final List<String> BANNED_WORDS = List.of(
// 한글 욕설/비속어
"시발", "씨발", "씨빨", "시팔", "씨팔", "ㅅㅂ", "ㅆㅂ",
"병신", "ㅄ", "ㅂㅅ", "지랄", "ㅈㄹ", "좆", "좃", "존나", "졸라",
"개새끼", "개색끼", "새끼", "쌍놈", "쌍년", "니미", "니애미", "느금마", "느그",
"닥쳐", "꺼져", "엿먹", "뒤져", "뒈져", "죽어버려", "자살해",
"창녀", "걸레년", "보지", "자지", "섹스", "야동",
// 영문 욕설
"fuck", "fucking", "motherfucker", "shit", "bitch", "asshole", "bastard", "dick", "cunt", "slut"
);
/** 부적절 표현 포함 여부. 정규화(소문자 + 공백·비문자 제거) 후 부분일치 검사. */
public boolean isObjectionable(String content) {
if (content == null || content.isBlank()) {
return false;
}
String normalized = normalize(content);
return BANNED_WORDS.stream().anyMatch(w -> normalized.contains(normalize(w)));
}
/** 부적절 표현이 있으면 400 예외를 던져 등록을 차단한다. */
public void check(String content) {
if (isObjectionable(content)) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"부적절한 표현(욕설·비방 등)이 포함되어 있어 등록할 수 없습니다. 커뮤니티 이용수칙을 확인해 주세요.");
}
}
/** 소문자화 + 유니코드 정규화 + 문자/숫자 외(공백·기호·자모 분리 우회 등) 제거 */
private String normalize(String s) {
String lower = Normalizer.normalize(s.toLowerCase(), Normalizer.Form.NFKC);
// 한글/영문/숫자만 남기고 제거 → "시 발", "s.h.i.t" 같은 우회 완화
return lower.replaceAll("[^0-9a-z\\uac00-\\ud7a3\\u3131-\\u318e]", "");
}
}
@@ -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
* - UP→DOWN 전환: 투표자 -1P(취소), 작성자 -1P(비추)
* - DOWN→UP 전환: 작성자 +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;
}
// UP→DOWN 전환: 투표자의 추천 보상 먼저 회수
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
+16
View File
@@ -33,6 +33,7 @@ ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_method VARCHAR(20) NULL COMME
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;
@@ -197,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,
+13
View File
@@ -130,3 +130,16 @@ CREATE TABLE IF NOT EXISTS comment_vote (
PRIMARY KEY (comment_id, member_id),
KEY idx_comment_vote_comment (comment_id, vote_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 사용자 차단 (App Store 가이드라인 1.2 UGC — "악성 사용자 차단" 요건).
-- blocker_id 가 blocked_id 를 차단하면, 목록·상세·댓글에서 blocked_id 의 콘텐츠가
-- blocker 화면에서 즉시 숨겨진다. reason 은 운영자 통지(신고 기록)에 사용.
CREATE TABLE IF NOT EXISTS user_block (
blocker_id BIGINT NOT NULL COMMENT '차단한 회원',
blocked_id BIGINT NOT NULL COMMENT '차단당한 회원',
reason VARCHAR(255) NULL COMMENT '차단 사유(운영자 통지용)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (blocker_id, blocked_id),
KEY idx_user_block_blocker (blocker_id),
KEY idx_user_block_blocked (blocked_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+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>
@@ -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>
+8 -1
View File
@@ -15,12 +15,19 @@
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findByPostId" parameterType="long" resultMap="commentResultMap">
<select id="findByPostId" resultMap="commentResultMap">
SELECT c.id, c.post_id, c.author_id, c.author_name, c.content, c.blocked, c.created_at,
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image
FROM comment c
LEFT JOIN member m ON m.id = c.author_id
WHERE c.post_id = #{postId}
<!-- 차단한 사용자의 댓글 숨김 -->
<if test="viewerId != null">
AND NOT EXISTS (
SELECT 1 FROM user_block ub
WHERE ub.blocker_id = #{viewerId} AND ub.blocked_id = c.author_id
)
</if>
ORDER BY c.id ASC
</select>
+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>
+12
View File
@@ -3,12 +3,23 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sb.web.board.mapper.PostMapper">
<!-- 차단한 사용자의 글 숨김 (viewerId 가 차단한 author 제외) -->
<sql id="blockFilter">
<if test="viewerId != null">
AND NOT EXISTS (
SELECT 1 FROM user_block ub
WHERE ub.blocker_id = #{viewerId} AND ub.blocked_id = p.author_id
)
</if>
</sql>
<sql id="filter">
<if test="tag != null and tag != ''">
JOIN post_tag pt ON pt.post_id = p.id
JOIN tag t ON t.id = pt.tag_id
</if>
<where>
<include refid="blockFilter"/>
<if test="category != null and category != ''">
AND p.category = #{category}
</if>
@@ -61,6 +72,7 @@
AND (#{category} IS NULL OR p.category = #{category})
AND p.created_at >= NOW() - INTERVAL #{hours} HOUR
AND (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') >= #{minUp}
<include refid="blockFilter"/>
ORDER BY (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') DESC, p.id DESC
LIMIT #{limit}
</select>
@@ -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>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sb.web.board.mapper.UserBlockMapper">
<!-- 차단 추가 (중복 시 사유만 갱신) -->
<insert id="upsert">
INSERT INTO user_block (blocker_id, blocked_id, reason, created_at)
VALUES (#{blockerId}, #{blockedId}, #{reason}, NOW())
ON DUPLICATE KEY UPDATE reason = VALUES(reason)
</insert>
<delete id="delete">
DELETE FROM user_block WHERE blocker_id = #{blockerId} AND blocked_id = #{blockedId}
</delete>
<select id="findBlockedIds" resultType="long">
SELECT blocked_id FROM user_block WHERE blocker_id = #{blockerId}
</select>
<select id="findBlockedUsers" resultType="com.sb.web.board.dto.BlockedUser">
SELECT ub.blocked_id AS blockedId,
m.name AS name,
m.google_picture AS googlePicture,
m.profile_image AS profileImage,
ub.reason AS reason,
ub.created_at AS createdAt
FROM user_block ub
LEFT JOIN member m ON m.id = ub.blocked_id
WHERE ub.blocker_id = #{blockerId}
ORDER BY ub.created_at DESC
</select>
<select id="listRecent" resultType="com.sb.web.board.dto.UserBlockNotice">
SELECT ub.blocker_id AS blockerId,
bm.name AS blockerName,
ub.blocked_id AS blockedId,
tm.name AS blockedName,
ub.reason AS reason,
ub.created_at AS createdAt
FROM user_block ub
LEFT JOIN member bm ON bm.id = ub.blocker_id
LEFT JOIN member tm ON tm.id = ub.blocked_id
ORDER BY ub.created_at DESC
LIMIT #{limit}
</select>
<delete id="deleteByMember">
DELETE FROM user_block WHERE blocker_id = #{memberId} OR blocked_id = #{memberId}
</delete>
</mapper>
+9 -4
View File
@@ -20,6 +20,7 @@
<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"/>
@@ -27,7 +28,7 @@
<sql id="cols">id, member_id, type, name, issuer, account_number, card_type,
opening_balance, opening_date, current_value,
loan_amount, loan_rate, loan_method, loan_months, loan_start,
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">
@@ -45,12 +46,12 @@
useGeneratedKeys="true" keyProperty="id">
INSERT INTO wallet (member_id, type, name, issuer, account_number, card_type,
opening_balance, opening_date, current_value,
loan_amount, loan_rate, loan_method, loan_months, loan_start,
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},
#{loanAmount}, #{loanRate}, #{loanMethod}, #{loanMonths}, #{loanStart},
#{loanAmount}, #{loanRate}, #{loanMethod}, #{loanMonths}, #{loanStart}, #{loanPayment},
#{sortOrder}, NOW(), NOW())
</insert>
@@ -63,7 +64,7 @@
current_value = #{currentValue},
loan_amount = #{loanAmount},
loan_rate = #{loanRate}, loan_method = #{loanMethod},
loan_months = #{loanMonths}, loan_start = #{loanStart}
loan_months = #{loanMonths}, loan_start = #{loanStart}, loan_payment = #{loanPayment}
WHERE id = #{id} AND member_id = #{memberId}
</update>
@@ -97,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>