Compare commits

..

37 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:01:31 +09:00
55 changed files with 1423 additions and 84 deletions
+3 -1
View File
@@ -36,6 +36,8 @@ jobs:
DB_PASSWORD: ci_root_pw DB_PASSWORD: ci_root_pw
REDIS_HOST: redis REDIS_HOST: redis
REDIS_PORT: '6379' REDIS_PORT: '6379'
# 컨텍스트 테스트(@SpringBootTest)가 스키마를 필요로 함(시작 시 기본분류 시더가 테이블 조회) → 운영과 동일하게 스키마 초기화
SQL_INIT_MODE: always
steps: steps:
- name: Checkout - name: Checkout
@@ -46,7 +48,7 @@ jobs:
with: with:
distribution: temurin distribution: temurin
java-version: '17' java-version: '17'
cache: gradle # cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
# clean build 는 test 태스크를 포함 — 단위테스트 실패 시 빌드/배포 차단(게이트) # clean build 는 test 태스크를 포함 — 단위테스트 실패 시 빌드/배포 차단(게이트)
- name: Build & Test - name: Build & Test
+8 -31
View File
@@ -18,33 +18,9 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# clean build 가 테스트(@SpringBootTest 컨텍스트 로드 포함)를 돌리므로 CI와 동일한 DB/Redis 필요 # 배포 잡은 테스트를 돌리지 않으므로 DB/Redis 서비스 컨테이너가 필요 없다.
services: # → 서비스 컨테이너 후처리(cleanup) hang 으로 "가짜 실패"가 나던 문제 회피.
mariadb: # 품질(테스트) 게이트는 ci.yaml 이 같은 push 에서 함께 수행한다.
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'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -54,13 +30,14 @@ jobs:
with: with:
distribution: temurin distribution: temurin
java-version: '17' java-version: '17'
cache: gradle # cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
# clean build 는 test 를 포함 — 테스트 실패 시 jar 가 생성되지 않아 배포 중단(게이트) # 테스트 없이 jar 만 빌드(bootJar) — 서비스 컨테이너/컨텍스트 테스트가 없어 후처리 hang 이 없다.
- name: Build jar (테스트 포함) # (테스트 게이트는 ci.yaml 이 담당)
- name: Build jar (테스트 제외, bootJar)
run: | run: |
chmod +x ./gradlew chmod +x ./gradlew
./gradlew --no-daemon clean build ./gradlew --no-daemon clean bootJar
- name: Deploy (SSH + systemd) - name: Deploy (SSH + systemd)
env: env:
+2
View File
@@ -24,6 +24,8 @@ dependencies {
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5' implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5'
// 비밀번호 BCrypt 해싱 (전체 Spring Security 미도입, crypto 모듈만 사용) // 비밀번호 BCrypt 해싱 (전체 Spring Security 미도입, crypto 모듈만 사용)
implementation 'org.springframework.security: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' compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'
@@ -56,7 +56,9 @@ public class AccountController {
public ResponseEntity<WalletResponse> createWallet( public ResponseEntity<WalletResponse> createWallet(
@Valid @RequestBody WalletRequest req, @Valid @RequestBody WalletRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { @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}") @PutMapping("/wallets/{id}")
@@ -250,6 +252,14 @@ public class AccountController {
return accountService.parseText(req); 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") @PostMapping("/entries/{id}/confirm")
public AccountEntryResponse confirm( public AccountEntryResponse confirm(
@@ -6,10 +6,11 @@ import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor; import com.sb.web.auth.web.AuthInterceptor;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** /**
@@ -30,4 +31,11 @@ public class BackupController {
backupService.restore(current.getId(), req); backupService.restore(current.getId(), req);
return ResponseEntity.noContent().build(); 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; 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.BudgetPeriodStat;
import com.sb.web.account.dto.BudgetRequest; import com.sb.web.account.dto.BudgetRequest;
import com.sb.web.account.dto.BudgetResponse; import com.sb.web.account.dto.BudgetResponse;
import com.sb.web.account.dto.BudgetStatus; import com.sb.web.account.dto.BudgetStatus;
import com.sb.web.account.dto.ExpectedIncomeRequest; import com.sb.web.account.dto.ExpectedIncomeRequest;
import com.sb.web.account.service.AccountSettingService; 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.account.service.BudgetService;
import com.sb.web.auth.dto.SessionUser; import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor; import com.sb.web.auth.web.AuthInterceptor;
@@ -26,6 +29,7 @@ import java.util.List;
public class BudgetController { public class BudgetController {
private final BudgetService budgetService; private final BudgetService budgetService;
private final BudgetOnetimeService onetimeService;
private final AccountSettingService settingService; private final AccountSettingService settingService;
/** 월 예상 수입 조회 (연·월별) */ /** 월 예상 수입 조회 (연·월별) */
@@ -110,4 +114,35 @@ public class BudgetController {
budgetService.delete(id, current.getId()); budgetService.delete(id, current.getId());
return ResponseEntity.noContent().build(); 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 org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -28,10 +29,12 @@ public class FxController {
public Map<String, Object> rate(@RequestParam String from, public Map<String, Object> rate(@RequestParam String from,
@RequestParam(defaultValue = "KRW") String to) { @RequestParam(defaultValue = "KRW") String to) {
BigDecimal rate = fxService.getRate(from, to); BigDecimal rate = fxService.getRate(from, to);
Instant updatedAt = fxService.getCachedAt(from);
Map<String, Object> res = new HashMap<>(); Map<String, Object> res = new HashMap<>();
res.put("from", from == null ? null : from.toUpperCase()); res.put("from", from == null ? null : from.toUpperCase());
res.put("to", to == null ? null : to.toUpperCase()); res.put("to", to == null ? null : to.toUpperCase());
res.put("rate", rate); // 조회 실패 시 null res.put("rate", rate); // 조회 실패 시 null
res.put("updatedAt", updatedAt != null ? updatedAt.toString() : null);
return res; return res;
} }
} }
@@ -1,5 +1,7 @@
package com.sb.web.account.controller; 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.account.service.VisionOcrService;
import com.sb.web.auth.dto.SessionUser; import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor; 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 org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 영수증 OCR API (/api/account/ocr/** — 로그인 필요). * 영수증 OCR API (/api/account/ocr/** — 로그인 필요).
* 이미지 업로드 → Google Vision 으로 텍스트 추출 → 프론트에서 금액·날짜·상호 파싱. * - /receipt : Google Vision 텍스트 추출(프론트 규칙 파서용, 폴백)
* - /receipt-ai: Claude Vision 으로 영수증을 구조화(금액·상호·날짜·분류·계좌). AI 미설정/실패 시 recognized=false.
*/ */
@RestController @RestController
@RequestMapping("/api/account/ocr") @RequestMapping("/api/account/ocr")
@@ -26,6 +32,7 @@ import java.util.Map;
public class OcrController { public class OcrController {
private final VisionOcrService visionOcrService; private final VisionOcrService visionOcrService;
private final AiEntryParser aiEntryParser;
@PostMapping("/receipt") @PostMapping("/receipt")
public Map<String, String> receipt( public Map<String, String> receipt(
@@ -37,4 +44,34 @@ public class OcrController {
String text = visionOcrService.recognize(image.getBytes()); String text = visionOcrService.recognize(image.getBytes());
return Map.of("text", text); 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( public ResponseEntity<RecurringResponse> create(
@Valid @RequestBody RecurringRequest req, @Valid @RequestBody RecurringRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { @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}") @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 String loanMethod; // 상환방식: EQUAL_PAYMENT / EQUAL_PRINCIPAL / BULLET (LOAN 전용)
private Integer loanMonths; // 대출기간(개월) (LOAN 전용) private Integer loanMonths; // 대출기간(개월) (LOAN 전용)
private LocalDate loanStart; // 대출시작일 (LOAN 전용) private LocalDate loanStart; // 대출시작일 (LOAN 전용)
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 은행 조회액과 정확히 맞추기 위한 선택 입력 (LOAN 전용)
private Integer sortOrder; // 표시 순서 (타입 내 정렬) private Integer sortOrder; // 표시 순서 (타입 내 정렬)
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; 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 lombok.Data;
import java.util.List;
/** /**
* 카드 결제 푸시 알림 원문 (네이티브 알림 리스너 → 백엔드). * 카드 결제 푸시 알림 원문 (네이티브 알림 리스너 → 백엔드).
* 빠른입력(자연어) 파싱 시에는 사용자의 분류·계좌 후보 목록을 함께 실어 AI가 그 중에서 고르게 한다.
*/ */
@Data @Data
public class NotificationRequest { public class NotificationRequest {
@@ -16,4 +19,10 @@ public class NotificationRequest {
/** 알림 본문 */ /** 알림 본문 */
private String text; private String text;
/** (선택) AI 파싱용 분류 후보 이름 목록 — 이 중에서 카테고리를 고르게 함(없으면 미추천) */
private List<String> categoryHints;
/** (선택) AI 파싱용 결제수단(계좌) 후보 이름 목록 — 텍스트에 결제수단 언급 시 이 중에서 고름 */
private List<String> walletHints;
} }
@@ -13,8 +13,11 @@ import java.time.LocalDate;
public class ParsedEntryResponse { public class ParsedEntryResponse {
private boolean recognized; // 거래로 인식되었는가 private boolean recognized; // 거래로 인식되었는가
private String type; // INCOME / EXPENSE private String type; // INCOME / EXPENSE / TRANSFER
private long amount; private long amount;
private String merchant; // 가맹점/적요(메모 후보) private String merchant; // 가맹점/적요(메모 후보)
private LocalDate date; 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 { public class WalletRequest {
@NotBlank(message = "유형을 선택하세요.") @NotBlank(message = "유형을 선택하세요.")
@Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.") @Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST|MINUS", message = "유형이 올바르지 않습니다.")
private String type; private String type;
@NotBlank(message = "이름을 입력하세요.") @NotBlank(message = "이름을 입력하세요.")
@@ -53,4 +53,5 @@ public class WalletRequest {
private Integer loanMonths; // 대출기간(개월) private Integer loanMonths; // 대출기간(개월)
private LocalDate loanStart; // 대출시작일 private LocalDate loanStart; // 대출시작일
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 선택
} }
@@ -38,6 +38,7 @@ public class WalletResponse {
private String loanMethod; // EQUAL_PAYMENT / EQUAL_PRINCIPAL / BULLET private String loanMethod; // EQUAL_PAYMENT / EQUAL_PRINCIPAL / BULLET
private Integer loanMonths; // 대출기간(개월) private Integer loanMonths; // 대출기간(개월)
private LocalDate loanStart; // 대출시작일 private LocalDate loanStart; // 대출시작일
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 선택 입력
public static WalletResponse from(Wallet w, long balance) { public static WalletResponse from(Wallet w, long balance) {
return WalletResponse.builder() return WalletResponse.builder()
@@ -56,6 +57,7 @@ public class WalletResponse {
.loanMethod(w.getLoanMethod()) .loanMethod(w.getLoanMethod())
.loanMonths(w.getLoanMonths()) .loanMonths(w.getLoanMonths())
.loanStart(w.getLoanStart()) .loanStart(w.getLoanStart())
.loanPayment(w.getLoanPayment())
.build(); .build();
} }
} }
@@ -96,4 +96,9 @@ public interface AccountEntryMapper {
/** 복수 항목의 태그를 한 번에 조회 — {entryId, tagName} 맵 반환 */ /** 복수 항목의 태그를 한 번에 조회 — {entryId, tagName} 맵 반환 */
List<Map<String, Object>> findTagsByEntryIds(@Param("entryIds") List<Long> entryIds); 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 deleteRecurrings(@Param("memberId") Long memberId);
int deleteBudgets(@Param("memberId") Long memberId); int deleteBudgets(@Param("memberId") Long memberId);
int deleteBudgetIncome(@Param("memberId") Long memberId); int deleteBudgetIncome(@Param("memberId") Long memberId);
int deleteBudgetOnetime(@Param("memberId") Long memberId);
int deleteQuickEntries(@Param("memberId") Long memberId); int deleteQuickEntries(@Param("memberId") Long memberId);
int deleteInvestTrades(@Param("memberId") Long memberId); int deleteInvestTrades(@Param("memberId") Long memberId);
int deleteInvestHoldings(@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); List<Recurring> findByMember(@Param("memberId") Long memberId);
/** 회원의 정기 거래 개수 (무료 회원 등록 한도 판정용) */
int countByMember(@Param("memberId") Long memberId);
/** 활성 규칙 (자동 생성용) */ /** 활성 규칙 (자동 생성용) */
List<Recurring> findActiveByMember(@Param("memberId") Long memberId); List<Recurring> findActiveByMember(@Param("memberId") Long memberId);
@@ -31,4 +31,7 @@ public interface WalletMapper {
/** 해당 타입의 현재 최대 sort_order (신규 계좌를 맨 뒤에 배치) */ /** 해당 타입의 현재 최대 sort_order (신규 계좌를 맨 뒤에 배치) */
Integer maxSortOrder(@Param("memberId") Long memberId, @Param("type") String type); 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.AccountEntryMapper;
import com.sb.web.account.mapper.AccountTagMapper; import com.sb.web.account.mapper.AccountTagMapper;
import com.sb.web.account.mapper.WalletMapper; 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.common.exception.ApiException;
import com.sb.web.point.PointService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -48,6 +51,9 @@ public class AccountService {
private final WalletMapper walletMapper; private final WalletMapper walletMapper;
private final InvestService investService; private final InvestService investService;
private final CardNotificationParser notificationParser; private final CardNotificationParser notificationParser;
private final AiEntryParser aiEntryParser;
private final PointService pointService;
private final MemberMapper memberMapper;
/* ===================== 항목 ===================== */ /* ===================== 항목 ===================== */
@@ -252,9 +258,29 @@ public class AccountService {
.build(); .build();
mapper.insert(entry); mapper.insert(entry);
applyTags(entry.getId(), req.getTagIds(), memberId); applyTags(entry.getId(), req.getTagIds(), memberId);
pointService.awardAccountEntry(memberId);
awardIfMonthComplete(entry.getEntryDate(), memberId);
return toResponse(mapper.findByIdAndMember(entry.getId(), 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 @Transactional
public AccountEntryResponse update(Long id, AccountEntryRequest req, Long memberId) { public AccountEntryResponse update(Long id, AccountEntryRequest req, Long memberId) {
AccountEntry entry = mustFind(id, memberId); AccountEntry entry = mustFind(id, memberId);
@@ -296,10 +322,19 @@ public class AccountService {
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성. * 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1). * 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
*/ */
/** 문자/푸시 텍스트만 파싱해 폼 자동채움용 결과 반환 (저장 안 함). iPhone 등 알림 자동수집 불가 대비. */ /**
* 문자/푸시·자연어 한 줄 텍스트를 파싱해 폼 자동채움용 결과 반환 (저장 안 함).
* AI 파서(Claude Haiku)가 설정돼 있으면 우선 시도("어제 스타벅스 5천원" 같은 자유 입력 처리),
* 미설정/실패/미인식이면 기존 규칙기반 카드알림 파서로 폴백.
*/
public ParsedEntryResponse parseText(NotificationRequest req) { public ParsedEntryResponse parseText(NotificationRequest req) {
CardNotificationParser.Parsed p = java.time.LocalDate today = java.time.LocalDate.now();
notificationParser.parse(req.getTitle(), req.getText(), 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() return ParsedEntryResponse.builder()
.recognized(p.isRecognized()) .recognized(p.isRecognized())
.type(p.isIncome() ? "INCOME" : "EXPENSE") .type(p.isIncome() ? "INCOME" : "EXPENSE")
@@ -309,6 +344,32 @@ public class AccountService {
.build(); .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 @Transactional
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) { public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
CardNotificationParser.Parsed p = CardNotificationParser.Parsed p =
@@ -513,8 +574,27 @@ public class AccountService {
.build(); .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 @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 wallet = toWallet(req);
wallet.setMemberId(memberId); wallet.setMemberId(memberId);
Integer max = walletMapper.maxSortOrder(memberId, req.getType()); Integer max = walletMapper.maxSortOrder(memberId, req.getType());
@@ -542,7 +622,7 @@ public class AccountService {
wallet.setType(req.getType()); wallet.setType(req.getType());
wallet.setName(req.getName().trim()); wallet.setName(req.getName().trim());
wallet.setIssuer(blankToNull(req.getIssuer())); 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.setCardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null);
wallet.setOpeningBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L); wallet.setOpeningBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L);
wallet.setOpeningDate(req.getOpeningDate()); wallet.setOpeningDate(req.getOpeningDate());
@@ -553,6 +633,7 @@ public class AccountService {
wallet.setLoanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null); wallet.setLoanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null);
wallet.setLoanMonths(isLoan ? req.getLoanMonths() : null); wallet.setLoanMonths(isLoan ? req.getLoanMonths() : null);
wallet.setLoanStart(isLoan ? req.getLoanStart() : null); wallet.setLoanStart(isLoan ? req.getLoanStart() : null);
wallet.setLoanPayment(isLoan ? req.getLoanPayment() : null);
walletMapper.update(wallet); walletMapper.update(wallet);
return WalletResponse.from(wallet, balanceMap(memberId).getOrDefault(id, wallet.getOpeningBalance())); return WalletResponse.from(wallet, balanceMap(memberId).getOrDefault(id, wallet.getOpeningBalance()));
} }
@@ -587,7 +668,7 @@ public class AccountService {
.type(req.getType()) .type(req.getType())
.name(req.getName().trim()) .name(req.getName().trim())
.issuer(blankToNull(req.getIssuer())) .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) .cardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null)
.openingBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L) .openingBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L)
.openingDate(req.getOpeningDate()) .openingDate(req.getOpeningDate())
@@ -597,6 +678,7 @@ public class AccountService {
.loanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null) .loanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null)
.loanMonths(isLoan ? req.getLoanMonths() : null) .loanMonths(isLoan ? req.getLoanMonths() : null)
.loanStart(isLoan ? req.getLoanStart() : null) .loanStart(isLoan ? req.getLoanStart() : null)
.loanPayment(isLoan ? req.getLoanPayment() : null)
.build(); .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 BudgetService budgetService;
private final QuickEntryService quickEntryService; 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 @Transactional
public void restore(Long memberId, RestoreRequest req) { public void restore(Long memberId, RestoreRequest req) {
if (isEmpty(req)) { if (isEmpty(req)) {
@@ -50,6 +67,7 @@ public class BackupService {
backupMapper.deleteRecurrings(memberId); backupMapper.deleteRecurrings(memberId);
backupMapper.deleteBudgets(memberId); backupMapper.deleteBudgets(memberId);
backupMapper.deleteBudgetIncome(memberId); backupMapper.deleteBudgetIncome(memberId);
backupMapper.deleteBudgetOnetime(memberId);
backupMapper.deleteQuickEntries(memberId); backupMapper.deleteQuickEntries(memberId);
backupMapper.deleteInvestTrades(memberId); backupMapper.deleteInvestTrades(memberId);
backupMapper.deleteInvestHoldings(memberId); backupMapper.deleteInvestHoldings(memberId);
@@ -71,7 +89,8 @@ public class BackupService {
wr.setOpeningBalance(w.getOpeningBalance()); wr.setOpeningBalance(w.getOpeningBalance());
wr.setOpeningDate(w.getOpeningDate()); wr.setOpeningDate(w.getOpeningDate());
wr.setCurrentValue(w.getCurrentValue()); 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); if (w.getOldId() != null) walletMap.put(w.getOldId(), newId);
} }
} }
@@ -152,7 +171,8 @@ public class BackupService {
rr.setStartDate(r.getStartDate()); rr.setStartDate(r.getStartDate());
rr.setEndDate(r.getEndDate()); rr.setEndDate(r.getEndDate());
rr.setActive(r.getActive() == null || r.getActive()); 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.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기본분류 불러오기. * 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기본분류 불러오기.
*/ */
@@ -6,6 +6,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; 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 static final String URL = "https://open.er-api.com/v6/latest/{base}";
private final RestClient restClient = RestClient.builder().build(); 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<>(); 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. */ /** from 통화 1단위 → to 통화 환율. 조회 실패 시 null. */
public BigDecimal getRate(String from, String to) { public BigDecimal getRate(String from, String to) {
if (from == null || to == null) return null; if (from == null || to == null) return null;
@@ -54,7 +63,7 @@ public class FxService {
if (rates.isMissingNode() || !rates.isObject()) { if (rates.isMissingNode() || !rates.isObject()) {
return c != null ? c.rates() : null; return c != null ? c.rates() : null;
} }
cache.put(base, new Cached(today, rates)); cache.put(base, new Cached(today, Instant.now(), rates));
return rates; return rates;
} catch (Exception e) { } catch (Exception e) {
log.warn("환율 조회 실패 base={}: {}", base, e.toString()); log.warn("환율 조회 실패 base={}: {}", base, e.toString());
@@ -30,8 +30,15 @@ public class RecurringService {
return recurringMapper.findByMember(memberId).stream().map(this::toResponse).toList(); return recurringMapper.findByMember(memberId).stream().map(this::toResponse).toList();
} }
// 무료 회원 고정지출 등록 한도(써보고 → 결제 유도). 유료·관리자는 무제한.
public static final int FREE_RECURRING_LIMIT = 2;
@Transactional @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); Recurring r = toEntity(req);
r.setMemberId(memberId); r.setMemberId(memberId);
recurringMapper.insert(r); 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()); 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") @PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest request) { public ResponseEntity<Void> logout(HttpServletRequest request) {
authService.logout(AuthInterceptor.resolveToken(request)); authService.logout(AuthInterceptor.resolveToken(request));
@@ -96,10 +102,15 @@ public class AuthController {
return java.util.Map.of("points", pointService.getPoints(current.getId())); return java.util.Map.of("points", pointService.getPoints(current.getId()));
} }
/** 포인트 적립/차감 내역 (최신순) */ /** 포인트 적립/차감 내역 — year+month 지정 시 해당 월 전건, 미지정 시 최근 100건 */
@GetMapping("/point-history") @GetMapping("/point-history")
public java.util.List<com.sb.web.point.PointHistoryResponse> pointHistory( 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) { @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
if (year != null && month != null) {
return pointService.getHistory(current.getId(), year, month);
}
return pointService.getHistory(current.getId()); return pointService.getHistory(current.getId());
} }
@@ -26,6 +26,7 @@ public class Member implements Serializable {
private String provider; // LOCAL / NAVER / GOOGLE private String provider; // LOCAL / NAVER / GOOGLE
private String providerId; // 소셜 제공자 측 고유 ID private String providerId; // 소셜 제공자 측 고유 ID
private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지) private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지)
private String appleId; // 애플 sub (계정 연결용 — 구글과 동일)
private String googlePicture; // 구글 아바타 URL (로그인 시 동기화) private String googlePicture; // 구글 아바타 URL (로그인 시 동기화)
private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜 private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
private String role; // USER / ADMIN 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 유지). */ /** 기존 계정에 구글 sub 연결 (provider 유지). */
int linkGoogle(@Param("id") Long id, @Param("googleId") String googleId); 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 countByLoginId(@Param("loginId") String loginId);
int insert(Member member); int insert(Member member);
@@ -64,4 +73,11 @@ public interface MemberMapper {
int updateStatus(@Param("id") Long id, @Param("status") String status); int updateStatus(@Param("id") Long id, @Param("status") String status);
int deleteById(@Param("id") Long id); int deleteById(@Param("id") Long id);
// ── 포인트 상점 ──────────────────────────────────────────────
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:}") @org.springframework.beans.factory.annotation.Value("${app.google-client-id:}")
private String googleClientId; 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 String SESSION_PREFIX = "session:";
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션 private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인(로그인 상태 유지) private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인(로그인 상태 유지)
@@ -241,6 +248,110 @@ public class AuthService {
return googleClientId == null ? "" : googleClientId; 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 을 갱신(슬라이딩 만료)한다. * 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다. * Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
@@ -19,6 +19,7 @@ public class ReportedItem {
private Long authorId; // 작성자 id private Long authorId; // 작성자 id
private String authorName; // 작성자 표시명 private String authorName; // 작성자 표시명
private Boolean blocked; // 이미 블라인드 되었는지 private Boolean blocked; // 이미 블라인드 되었는지
private String blockReason; // 블라인드 사유 (글만 존재; 스팸 감지 / 신고 누적 )
private Integer reportCount; // 누적 신고 private Integer reportCount; // 누적 신고
private LocalDateTime lastReportedAt; // 마지막 신고 시각 private LocalDateTime lastReportedAt; // 마지막 신고 시각
} }
@@ -191,7 +191,11 @@ public class BoardService {
.build(); .build();
postMapper.insert(post); postMapper.insert(post);
applyTags(post.getId(), req.getTagIds()); 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); return assemble(mustFindPost(post.getId()), author);
} }
@@ -245,7 +249,11 @@ public class BoardService {
.content(req.getContent()) .content(req.getContent())
.build(); .build();
commentMapper.insert(comment); 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())); return CommentResponse.from(commentMapper.findById(comment.getId()));
} }
@@ -328,6 +336,7 @@ public class BoardService {
} else { } else {
voteMapper.upsertPostVote(postId, memberId, type); voteMapper.upsertPostVote(postId, memberId, type);
} }
pointService.applyVotePoints(post.getAuthorId(), memberId, existing, type);
return new VoteResponse( return new VoteResponse(
voteMapper.countPostVotes(postId, UP), voteMapper.countPostVotes(postId, UP),
voteMapper.countPostVotes(postId, DOWN), voteMapper.countPostVotes(postId, DOWN),
@@ -352,6 +361,7 @@ public class BoardService {
} else { } else {
voteMapper.upsertCommentVote(commentId, memberId, type); voteMapper.upsertCommentVote(commentId, memberId, type);
} }
pointService.applyVotePoints(comment.getAuthorId(), memberId, existing, type);
return new VoteResponse( return new VoteResponse(
voteMapper.countCommentVotes(commentId, UP), voteMapper.countCommentVotes(commentId, UP),
voteMapper.countCommentVotes(commentId, DOWN), voteMapper.countCommentVotes(commentId, DOWN),
@@ -41,7 +41,6 @@ public class WebConfig implements WebMvcConfigurer {
"/api/account/category-stats", "/api/account/category-stats",
"/api/account/networth/trend", "/api/account/networth/trend",
"/api/account/budgets", "/api/account/budgets/**", "/api/account/budgets", "/api/account/budgets/**",
"/api/account/recurrings", "/api/account/recurrings/**",
"/api/account/ocr", "/api/account/ocr/**", "/api/account/ocr", "/api/account/ocr/**",
"/api/account/invest", "/api/account/invest/**", "/api/account/invest", "/api/account/invest/**",
"/api/account/tags", "/api/account/tags/**", "/api/account/tags", "/api/account/tags/**",
+144 -11
View File
@@ -5,9 +5,18 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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 @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -15,32 +24,156 @@ public class PointService {
private final PointMapper pointMapper; 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_POINT = 10;
public static final String REASON_BOARD_WRITE = "BOARD_WRITE"; 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 @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); int today = pointMapper.countTodayGrants(memberId, REASON_BOARD_WRITE);
if (today >= BOARD_WRITE_DAILY_LIMIT) { if (today >= BOARD_WRITE_DAILY_LIMIT) return 0;
return 0;
}
pointMapper.insertHistory(memberId, BOARD_WRITE_POINT, REASON_BOARD_WRITE); pointMapper.insertHistory(memberId, BOARD_WRITE_POINT, REASON_BOARD_WRITE);
pointMapper.addPoints(memberId, BOARD_WRITE_POINT); pointMapper.addPoints(memberId, BOARD_WRITE_POINT);
return BOARD_WRITE_POINT; return BOARD_WRITE_POINT;
} }
/**
* 투표 포인트 처리. 이전 투표(existing) 요청(newType) 비교해 적절한 포인트를 지급/차감한다.
*
* 케이스:
* - existing==newType (취소): UP 취소 투표자 -1P / DOWN 취소 변동 없음
* - UP: 작성자 +5P( 50P 한도), 투표자 +1P( 10P 한도)
* - DOWN: 작성자 -1P
* - UPDOWN 전환: 투표자 -1P(취소), 작성자 -1P(비추)
* - DOWNUP 전환: 작성자 +5P, 투표자 +1P
*/
@Transactional
public void applyVotePoints(Long authorId, Long voterId, String existing, String newType) {
boolean cancel = newType.equals(existing);
if (cancel) {
// 같은 타입 재요청 = 취소
if ("UP".equals(existing)) {
// 투표자가 받았던 +1P 회수
pointMapper.insertHistory(voterId, VOTE_CANCEL_POINT, REASON_VOTE_CANCEL);
pointMapper.addPoints(voterId, VOTE_CANCEL_POINT);
}
// DOWN 취소: 포인트 변동 없음
return;
}
// UPDOWN 전환: 투표자의 추천 보상 먼저 회수
if ("UP".equals(existing)) {
pointMapper.insertHistory(voterId, VOTE_CANCEL_POINT, REASON_VOTE_CANCEL);
pointMapper.addPoints(voterId, VOTE_CANCEL_POINT);
}
if ("UP".equals(newType)) {
// 작성자: +5P ( 한도)
if (pointMapper.countTodayGrants(authorId, REASON_POST_UPVOTED) < POST_UPVOTED_DAILY_CAP) {
pointMapper.insertHistory(authorId, POST_UPVOTED_POINT, REASON_POST_UPVOTED);
pointMapper.addPoints(authorId, POST_UPVOTED_POINT);
}
// 투표자: +1P ( 한도)
if (pointMapper.countTodayGrants(voterId, REASON_VOTE_REWARD) < VOTE_REWARD_DAILY_CAP) {
pointMapper.insertHistory(voterId, VOTE_REWARD_POINT, REASON_VOTE_REWARD);
pointMapper.addPoints(voterId, VOTE_REWARD_POINT);
}
} else if ("DOWN".equals(newType)) {
// 작성자: -1P
pointMapper.insertHistory(authorId, POST_DOWNVOTED_POINT, REASON_POST_DOWNVOTED);
pointMapper.addPoints(authorId, POST_DOWNVOTED_POINT);
}
}
/**
* 가계부 내역 작성 보상. 건당 10P, 하루 5건(50P)까지만 지급한다.
* @return 지급되면 지급 포인트, 한도 초과로 미지급이면 0
*/
@Transactional
public int awardAccountEntry(Long memberId) {
int today = pointMapper.countTodayGrants(memberId, REASON_ACCOUNT_ENTRY);
if (today >= ACCOUNT_ENTRY_DAILY_LIMIT) {
return 0;
}
pointMapper.insertHistory(memberId, ACCOUNT_ENTRY_POINT, REASON_ACCOUNT_ENTRY);
pointMapper.addPoints(memberId, ACCOUNT_ENTRY_POINT);
return ACCOUNT_ENTRY_POINT;
}
/**
* 달의 모든 날에 기록이 있을 주는 랜덤 보너스(1~100P). 1회만 지급한다.
* reason 연월을 인코딩(MONTH_COMPLETE_yyyyMM) 같은 중복 지급을 막는다.
* @return 지급 포인트(이미 지급됐으면 0)
*/
@Transactional
public int awardMonthComplete(Long memberId, int year, int month) {
String reason = REASON_MONTH_COMPLETE_PREFIX + String.format("%04d%02d", year, month);
if (pointMapper.countByReason(memberId, reason) > 0) return 0; // 이미 지급됨
int amount = java.util.concurrent.ThreadLocalRandom.current().nextInt(1, MONTH_COMPLETE_MAX_POINT + 1); // 1~100
pointMapper.insertHistory(memberId, amount, reason);
pointMapper.addPoints(memberId, amount);
return amount;
}
public long getPoints(Long memberId) { public long getPoints(Long memberId) {
Long p = pointMapper.getPoints(memberId); Long p = pointMapper.getPoints(memberId);
return p == null ? 0L : p; return p == null ? 0L : p;
} }
/** 최근 포인트 적립/차감 내역 (최신순) */ /** 최근 포인트 적립/차감 내역 (최신순, 최대 100건) */
public java.util.List<PointHistoryResponse> getHistory(Long memberId) { public java.util.List<PointHistoryResponse> getHistory(Long memberId) {
return pointMapper.findHistory(memberId, 100); 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); 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 insertHistory(@Param("memberId") Long memberId, @Param("amount") int amount, @Param("reason") String reason);
int addPoints(@Param("memberId") Long memberId, @Param("amount") int amount); int addPoints(@Param("memberId") Long memberId, @Param("amount") int amount);
@@ -23,4 +26,7 @@ public interface PointMapper {
/** 최근 적립/차감 내역 (최신순, 최대 limit건) */ /** 최근 적립/차감 내역 (최신순, 최대 limit건) */
List<PointHistoryResponse> findHistory(@Param("memberId") Long memberId, @Param("limit") int 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);
} }
+5
View File
@@ -79,6 +79,11 @@ app:
public-url: ${PUBLIC_URL:https://app.sblog.kr} public-url: ${PUBLIC_URL:https://app.sblog.kr}
# 구글 OAuth 웹 클라이언트 ID (Google Cloud Console 발급). 미설정 시 구글 로그인 비활성. # 구글 OAuth 웹 클라이언트 ID (Google Cloud Console 발급). 미설정 시 구글 로그인 비활성.
google-client-id: ${GOOGLE_CLIENT_ID:} 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 =====
logging: logging:
+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_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_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_amount BIGINT NULL COMMENT '대출 실행 금액(원금, 처음 빌린 금액)';
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_payment BIGINT NULL COMMENT '월 상환금(고정, 원리금균등) — 은행 조회액과 맞추기 위한 선택 입력';
-- 계좌번호 암호화 저장(AES-GCM)으로 평문보다 길어 VARCHAR(255) 필요 — 운영 적용 완료(CREATE TABLE 정의에 반영). -- 계좌번호 암호화 저장(AES-GCM)으로 평문보다 길어 VARCHAR(255) 필요 — 운영 적용 완료(CREATE TABLE 정의에 반영).
-- 매 기동 MODIFY 는 라이브 테이블 락 위험이 있어 제거. 기존 환경 확장이 필요하면 1회 수동 적용: -- 매 기동 MODIFY 는 라이브 테이블 락 위험이 있어 제거. 기존 환경 확장이 필요하면 1회 수동 적용:
-- ALTER TABLE wallet MODIFY account_number VARCHAR(255) NULL; -- ALTER TABLE wallet MODIFY account_number VARCHAR(255) NULL;
@@ -197,6 +198,21 @@ CREATE TABLE IF NOT EXISTS budget_income (
PRIMARY KEY (member_id, `year`, `month`) PRIMARY KEY (member_id, `year`, `month`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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 지갑별 · 사용자별) -- 투자 보유 종목 (INVEST 지갑별 · 사용자별)
CREATE TABLE IF NOT EXISTS invest_holding ( CREATE TABLE IF NOT EXISTS invest_holding (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
+8
View File
@@ -27,6 +27,10 @@ ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_google_id (google_id);
-- 기존 구글 계정(provider=GOOGLE)의 sub 를 google_id 로 백필(멱등 — 이미 채워졌으면 아무 것도 안 함). -- 기존 구글 계정(provider=GOOGLE)의 sub 를 google_id 로 백필(멱등 — 이미 채워졌으면 아무 것도 안 함).
UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_id IS NULL AND provider_id IS NOT NULL; UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_id IS NULL AND provider_id IS NOT NULL;
-- 애플 로그인: 애플 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 로 시작 (멱등). -- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등).
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role; 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) KEY idx_iap_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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 ( CREATE TABLE IF NOT EXISTS point_history (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
@@ -246,4 +246,13 @@
ORDER BY aet.entry_id, t.name ORDER BY aet.entry_id, t.name
</select> </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> </mapper>
@@ -19,6 +19,9 @@
<delete id="deleteBudgetIncome"> <delete id="deleteBudgetIncome">
DELETE FROM budget_income WHERE member_id = #{memberId} DELETE FROM budget_income WHERE member_id = #{memberId}
</delete> </delete>
<delete id="deleteBudgetOnetime">
DELETE FROM budget_onetime WHERE member_id = #{memberId}
</delete>
<delete id="deleteQuickEntries"> <delete id="deleteQuickEntries">
DELETE FROM quick_entry WHERE member_id = #{memberId} DELETE FROM quick_entry WHERE member_id = #{memberId}
</delete> </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>
+40 -3
View File
@@ -12,6 +12,7 @@
<result property="provider" column="provider"/> <result property="provider" column="provider"/>
<result property="providerId" column="provider_id"/> <result property="providerId" column="provider_id"/>
<result property="googleId" column="google_id"/> <result property="googleId" column="google_id"/>
<result property="appleId" column="apple_id"/>
<result property="googlePicture" column="google_picture"/> <result property="googlePicture" column="google_picture"/>
<result property="profileImage" column="profile_image"/> <result property="profileImage" column="profile_image"/>
<result property="role" column="role"/> <result property="role" column="role"/>
@@ -26,7 +27,7 @@
</resultMap> </resultMap>
<sql id="columns"> <sql id="columns">
id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, plan_expires_at, 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> </sql>
<select id="findById" parameterType="long" resultMap="memberResultMap"> <select id="findById" parameterType="long" resultMap="memberResultMap">
@@ -68,15 +69,34 @@
UPDATE member SET google_id = #{googleId}, updated_at = NOW() WHERE id = #{id} UPDATE member SET google_id = #{googleId}, updated_at = NOW() WHERE id = #{id}
</update> </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 id="countByLoginId" parameterType="string" resultType="int">
SELECT COUNT(*) FROM member WHERE login_id = #{loginId} SELECT COUNT(*) FROM member WHERE login_id = #{loginId}
</select> </select>
<insert id="insert" parameterType="com.sb.web.auth.domain.Member" <insert id="insert" parameterType="com.sb.web.auth.domain.Member"
useGeneratedKeys="true" keyProperty="id"> useGeneratedKeys="true" keyProperty="id">
INSERT INTO member (login_id, password, name, email, provider, provider_id, google_id, 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}, VALUES (#{loginId}, #{password}, #{name}, #{email},
#{provider}, #{providerId}, #{googleId}, #{googlePicture}, #{role}, #{status}, NOW(), NOW()) #{provider}, #{providerId}, #{googleId}, #{appleId}, #{googlePicture}, #{role}, #{status}, NOW(), NOW())
</insert> </insert>
<update id="updatePassword"> <update id="updatePassword">
@@ -132,4 +152,21 @@
DELETE FROM member WHERE id = #{id} DELETE FROM member WHERE id = #{id}
</delete> </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> </mapper>
+15 -1
View File
@@ -8,13 +8,18 @@
WHERE member_id = #{memberId} AND reason = #{reason} AND created_at &gt;= CURDATE() WHERE member_id = #{memberId} AND reason = #{reason} AND created_at &gt;= CURDATE()
</select> </select>
<select id="countByReason" resultType="int">
SELECT COUNT(*) FROM point_history
WHERE member_id = #{memberId} AND reason = #{reason}
</select>
<insert id="insertHistory"> <insert id="insertHistory">
INSERT INTO point_history (member_id, amount, reason, created_at) INSERT INTO point_history (member_id, amount, reason, created_at)
VALUES (#{memberId}, #{amount}, #{reason}, NOW()) VALUES (#{memberId}, #{amount}, #{reason}, NOW())
</insert> </insert>
<update id="addPoints"> <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> </update>
<select id="getPoints" parameterType="long" resultType="long"> <select id="getPoints" parameterType="long" resultType="long">
@@ -29,4 +34,13 @@
LIMIT #{limit} LIMIT #{limit}
</select> </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> </mapper>
@@ -78,4 +78,8 @@
UPDATE recurring SET last_run_date = #{lastRunDate} WHERE id = #{id} UPDATE recurring SET last_run_date = #{lastRunDate} WHERE id = #{id}
</update> </update>
<select id="countByMember" parameterType="long" resultType="int">
SELECT COUNT(*) FROM recurring WHERE member_id = #{memberId}
</select>
</mapper> </mapper>
+15 -11
View File
@@ -12,25 +12,29 @@
SELECT COUNT(*) FROM report WHERE target_type = #{targetType} AND target_id = #{targetId} SELECT COUNT(*) FROM report WHERE target_type = #{targetType} AND target_id = #{targetId}
</select> </select>
<!-- 신고/댓글 목록 (대상별 집계). 신고 많은 순 → 최근 신고 순. --> <!-- 신고/스팸·댓글 목록. 신고 1건 이상 OR 스팸 감지 블라인드 항목을 모두 포함. -->
<select id="listReported" resultType="com.sb.web.board.dto.ReportedItem"> <select id="listReported" resultType="com.sb.web.board.dto.ReportedItem">
SELECT 'POST' AS targetType, r.target_id AS targetId, p.id AS postId, 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.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.author_id AS authorId, p.author_name AS authorName, p.blocked AS blocked,
COUNT(*) AS reportCount, MAX(r.created_at) AS lastReportedAt p.block_reason AS blockReason,
FROM report r COUNT(r.id) AS reportCount, MAX(r.created_at) AS lastReportedAt
JOIN post p ON p.id = r.target_id FROM post p
WHERE r.target_type = 'POST' 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 GROUP BY p.id
UNION ALL UNION ALL
SELECT 'COMMENT' AS targetType, r.target_id AS targetId, c.post_id AS postId, 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, 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, c.author_id AS authorId, c.author_name AS authorName, c.blocked AS blocked,
COUNT(*) AS reportCount, MAX(r.created_at) AS lastReportedAt NULL AS blockReason,
FROM report r COUNT(r.id) AS reportCount, MAX(r.created_at) AS lastReportedAt
JOIN comment c ON c.id = r.target_id FROM comment c
JOIN post pp ON pp.id = c.post_id JOIN post pp ON pp.id = c.post_id
WHERE r.target_type = 'COMMENT' 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 GROUP BY c.id
ORDER BY reportCount DESC, lastReportedAt DESC ORDER BY reportCount DESC, lastReportedAt DESC
</select> </select>
+9 -4
View File
@@ -20,6 +20,7 @@
<result property="loanMethod" column="loan_method"/> <result property="loanMethod" column="loan_method"/>
<result property="loanMonths" column="loan_months"/> <result property="loanMonths" column="loan_months"/>
<result property="loanStart" column="loan_start"/> <result property="loanStart" column="loan_start"/>
<result property="loanPayment" column="loan_payment"/>
<result property="sortOrder" column="sort_order"/> <result property="sortOrder" column="sort_order"/>
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/> <result property="updatedAt" column="updated_at"/>
@@ -27,7 +28,7 @@
<sql id="cols">id, member_id, type, name, issuer, account_number, card_type, <sql id="cols">id, member_id, type, name, issuer, account_number, card_type,
opening_balance, opening_date, current_value, 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> sort_order, created_at, updated_at</sql>
<select id="findByMember" parameterType="long" resultMap="walletResultMap"> <select id="findByMember" parameterType="long" resultMap="walletResultMap">
@@ -45,12 +46,12 @@
useGeneratedKeys="true" keyProperty="id"> useGeneratedKeys="true" keyProperty="id">
INSERT INTO wallet (member_id, type, name, issuer, account_number, card_type, INSERT INTO wallet (member_id, type, name, issuer, account_number, card_type,
opening_balance, opening_date, current_value, 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) sort_order, created_at, updated_at)
VALUES (#{memberId}, #{type}, #{name}, #{issuer}, VALUES (#{memberId}, #{type}, #{name}, #{issuer},
#{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler}, #{cardType}, #{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler}, #{cardType},
#{openingBalance}, #{openingDate}, #{currentValue}, #{openingBalance}, #{openingDate}, #{currentValue},
#{loanAmount}, #{loanRate}, #{loanMethod}, #{loanMonths}, #{loanStart}, #{loanAmount}, #{loanRate}, #{loanMethod}, #{loanMonths}, #{loanStart}, #{loanPayment},
#{sortOrder}, NOW(), NOW()) #{sortOrder}, NOW(), NOW())
</insert> </insert>
@@ -63,7 +64,7 @@
current_value = #{currentValue}, current_value = #{currentValue},
loan_amount = #{loanAmount}, loan_amount = #{loanAmount},
loan_rate = #{loanRate}, loan_method = #{loanMethod}, 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} WHERE id = #{id} AND member_id = #{memberId}
</update> </update>
@@ -97,4 +98,8 @@
SELECT MAX(sort_order) FROM wallet WHERE member_id = #{memberId} AND type = #{type} SELECT MAX(sort_order) FROM wallet WHERE member_id = #{memberId} AND type = #{type}
</select> </select>
<select id="countByType" resultType="int">
SELECT COUNT(*) FROM wallet WHERE member_id = #{memberId} AND type = #{type}
</select>
</mapper> </mapper>