Compare commits
172 Commits
9243a41385
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b6976f5612 | |||
| 4b23632314 | |||
| 6db6cb055e | |||
| 75d5c9fa9b | |||
| 22c492c1d2 | |||
| 795afc80ff | |||
| ee644515cb | |||
| ee2bc08b1e | |||
| 646d5f53fa | |||
| 0780546d4f | |||
| 489c3aacb7 | |||
| 4132a17c6a | |||
| 29b14a5464 | |||
| cc36f9e939 | |||
| 56197c2689 | |||
| 45bb025247 | |||
| a0509099f7 | |||
| 24858f23e8 | |||
| 7d15f06390 | |||
| 2802ec9d5a | |||
| a34a8250a4 | |||
| 4aeac2ac0f | |||
| 655af7af63 | |||
| c5f16795ef | |||
| 28670b69a1 | |||
| 53326f089b | |||
| 82eea0b908 | |||
| b3b42f0db6 | |||
| 32da57e0e0 | |||
| d65dd88bba | |||
| 30cb68c003 | |||
| 811cc1b391 | |||
| 0082e647c2 | |||
| 9b5abedd40 | |||
| 9fdec36758 | |||
| 1d90f065e5 | |||
| a10e16c1af | |||
| 2f376e6f77 | |||
| 11d1655261 | |||
| 8ced77480e | |||
| 9ed80f7085 | |||
| 9b04ad3310 | |||
| f62889ac62 | |||
| 511843f1b6 | |||
| 79fc8fea16 | |||
| 190fbe835f | |||
| 3ceb3c4253 | |||
| 947c7a1f68 | |||
| 186aec7bb4 | |||
| 24e235404b | |||
| 3535c2e220 | |||
| 10f51976d9 | |||
| 204c5ce430 | |||
| b969ba6104 | |||
| 706deb057e | |||
| ddcd6f4335 | |||
| e5d1b5d40c | |||
| ba75613e1c | |||
| d028d957b6 | |||
| a47b8405f4 | |||
| f28ecd853e | |||
| 8142b8b518 | |||
| 5fec464f90 | |||
| be15f5b85d | |||
| 483aaa6473 | |||
| a4b6c3fd2d | |||
| 5bd2616de9 | |||
| d5366e8e72 | |||
| ad40af18b2 | |||
| 1e93280140 | |||
| 95d84c8138 | |||
| d1c13e7cc1 | |||
| 629ab1f811 | |||
| 08338d516f | |||
| b94f162f7e | |||
| dc4e99a6db | |||
| caaad65ca2 | |||
| f408715e17 | |||
| b4344f5270 | |||
| 74a2cae9ef | |||
| ef9dace1df | |||
| 98f5f51112 | |||
| 6e269888c2 | |||
| 6256d3e63d | |||
| 39a11d299c | |||
| 93e06c9475 | |||
| f19f653715 | |||
| f130d53f62 | |||
| 16ddf88de3 | |||
| 3b46d2ba09 | |||
| d794549f66 | |||
| bf6fb97ed8 | |||
| 0e26237577 | |||
| 8d8d26ec3a | |||
| 1646ec0a7f | |||
| b48d8c667c | |||
| fe0837cfd6 | |||
| 09efd65fa9 | |||
| 68f6c985bc | |||
| a1697a7a22 | |||
| 47a01356a5 | |||
| 63b3ffea24 | |||
| 236332637a | |||
| 5f21cc4474 | |||
| 0e71e7b8cc | |||
| 3fc233d697 | |||
| 9d4a415f2e | |||
| 7de643022b | |||
| 5230ba1587 | |||
| 6eef6cbd29 | |||
| 1f8b968588 | |||
| 553855b31c | |||
| b90f9ea3b8 | |||
| a0fc546ad8 | |||
| a61700bdf6 | |||
| 120e042971 | |||
| 484732c5ed | |||
| 31590343cf | |||
| e3a24848bc | |||
| 55c47a43e6 | |||
| fd25968d0e | |||
| f9abd0c989 | |||
| c61cec6dad | |||
| 2da43b2f77 | |||
| 2eef5c3b3e | |||
| eded44a476 | |||
| 4f8ade5ff3 | |||
| a9ff2387c8 | |||
| 8bfe596f4e | |||
| 4a85902183 | |||
| 76bbf195df | |||
| b748f7d48d | |||
| b62b51b789 | |||
| e9a3fc82e5 | |||
| 91cc5558e4 | |||
| e592c174bf | |||
| 27b8829def | |||
| 5e2bdae37d | |||
| ad2ee4e75d | |||
| c05be0880c | |||
| eb24a315d3 | |||
| fb94df8112 | |||
| 396e986792 | |||
| 8fec057752 | |||
| 191f1cfbe4 | |||
| c1f97364aa | |||
| 1d4dbc2183 | |||
| bd0a0f7776 | |||
| 478a75b6d8 | |||
| f5e9b78a14 | |||
| 41a5780e14 | |||
| cac9919cc5 | |||
| 86c56f6c6f | |||
| 9ad6a91a56 | |||
| bd1164866c | |||
| f55d998803 | |||
| f884cb7b06 | |||
| 2ecdd363b1 | |||
| e5fd31843a | |||
| 8eb2129f5e | |||
| a821cd8f89 | |||
| eb6b9830ce | |||
| cf275fabf6 | |||
| 43d1b53d01 | |||
| d373e3e9e4 | |||
| cf6d0710f6 | |||
| f79c2412fd | |||
| 614e74be8a | |||
| f3230dcc5b | |||
| 8639ca3d04 | |||
| ef7d0f7f83 | |||
| 2a2f81dc32 |
@@ -0,0 +1,13 @@
|
||||
# DB 접속 정보 템플릿. 이 파일을 복사해 .env 로 만들고 실제 값으로 채우세요.
|
||||
# cp .env.example .env
|
||||
# .env 는 git 에 커밋되지 않습니다.
|
||||
# --- MariaDB ---
|
||||
DB_URL=jdbc:mariadb://<host>:3306/sb_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
|
||||
DB_USERNAME=<username>
|
||||
DB_PASSWORD=<password>
|
||||
|
||||
# --- Redis ---
|
||||
REDIS_HOST=<host>
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DATABASE=0
|
||||
@@ -0,0 +1,73 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# main 은 Deploy(clean build=테스트 포함)가 게이트 역할을 하므로 CI 중복 실행 제외(배포 시간 단축).
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: ci_root_pw
|
||||
MARIADB_DATABASE: sb_db
|
||||
options: >-
|
||||
--health-cmd="healthcheck.sh --connect --innodb_initialized"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=15
|
||||
redis:
|
||||
image: redis:7
|
||||
options: >-
|
||||
--health-cmd="redis-cli ping"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=10
|
||||
|
||||
env:
|
||||
DB_URL: jdbc:mariadb://mariadb:3306/sb_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
|
||||
DB_USERNAME: root
|
||||
DB_PASSWORD: ci_root_pw
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: '6379'
|
||||
# 컨텍스트 테스트(@SpringBootTest)가 스키마를 필요로 함(시작 시 기본분류 시더가 테이블 조회) → 운영과 동일하게 스키마 초기화
|
||||
SQL_INIT_MODE: always
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
|
||||
|
||||
# clean build 는 test 태스크를 포함 — 단위테스트 실패 시 빌드/배포 차단(게이트)
|
||||
- name: Build & Test
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew --no-daemon clean build
|
||||
|
||||
# 테스트 결과 리포트 — 성공/실패 무관하게 항상 업로드(실패 원인 확인용)
|
||||
- name: Upload test report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-report
|
||||
path: build/reports/tests/test
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload jar
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: app-jar
|
||||
path: build/libs/*.jar
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Deploy
|
||||
|
||||
# main 푸시 시 자동 배포: jar 빌드 → 서버로 scp → systemd 재시작
|
||||
#
|
||||
# 저장소 Settings > Actions > Secrets 에 등록:
|
||||
# DEPLOY_HOST 배포 서버 호스트/IP
|
||||
# DEPLOY_USER SSH 계정 (systemctl 무비밀번호 sudo 필요 — deploy/README.md 참고)
|
||||
# DEPLOY_SSH_KEY SSH 개인키 (PEM 전체)
|
||||
# DEPLOY_PATH jar 배치 디렉터리 (예: /opt/sb-backend)
|
||||
# DEPLOY_PORT SSH 포트 (선택, 기본 22)
|
||||
# SERVICE_NAME systemd 유닛명 (선택, 기본 sb-backend)
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# 배포 잡은 테스트를 돌리지 않으므로 DB/Redis 서비스 컨테이너가 필요 없다.
|
||||
# → 서비스 컨테이너 후처리(cleanup) hang 으로 "가짜 실패"가 나던 문제 회피.
|
||||
# 품질(테스트) 게이트는 ci.yaml 이 같은 push 에서 함께 수행한다.
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
|
||||
|
||||
# 테스트 없이 jar 만 빌드(bootJar) — 서비스 컨테이너/컨텍스트 테스트가 없어 후처리 hang 이 없다.
|
||||
# (테스트 게이트는 ci.yaml 이 담당)
|
||||
- name: Build jar (테스트 제외, bootJar)
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew --no-daemon clean bootJar
|
||||
|
||||
- name: Deploy (SSH + systemd)
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
SERVICE_NAME: ${{ secrets.SERVICE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT="${DEPLOY_PORT:-22}"
|
||||
SERVICE="${SERVICE_NAME:-sb-backend}"
|
||||
DEST="${DEPLOY_PATH:-}"
|
||||
if [ -z "$DEST" ] || [ "$DEST" = "/" ]; then
|
||||
echo "::error::DEPLOY_PATH 시크릿이 비어 있거나 잘못되었습니다."; exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" | tr -d '\r' > ~/.ssh/id_deploy
|
||||
chmod 600 ~/.ssh/id_deploy
|
||||
ssh-keyscan -p "$PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
SSH="ssh -p $PORT -i $HOME/.ssh/id_deploy -o StrictHostKeyChecking=yes"
|
||||
|
||||
JAR=$(ls build/libs/*.jar | grep -v -- '-plain.jar' | head -n1)
|
||||
echo "Deploy $JAR -> $DEPLOY_USER@$DEPLOY_HOST:$DEST/app.jar"
|
||||
|
||||
$SSH "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p '$DEST'"
|
||||
scp -P "$PORT" -i "$HOME/.ssh/id_deploy" "$JAR" "$DEPLOY_USER@$DEPLOY_HOST:$DEST/app.jar"
|
||||
$SSH "$DEPLOY_USER@$DEPLOY_HOST" "sudo systemctl restart '$SERVICE'"
|
||||
# 기동 대기: 최대 ~40초 폴링, 안 뜨면 상태 덤프 후 실패
|
||||
$SSH "$DEPLOY_USER@$DEPLOY_HOST" "for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do systemctl is-active --quiet '$SERVICE' && echo 'service active' && exit 0; sleep 2; done; echo '서비스가 active 상태가 아닙니다:'; systemctl --no-pager -l status '$SERVICE' | tail -30; exit 1"
|
||||
echo "✅ 배포 완료"
|
||||
@@ -1,6 +1,13 @@
|
||||
HELP.md
|
||||
.gradle
|
||||
build/
|
||||
|
||||
# 로컬 전용 테스트/시드 스크립트 (실제 이메일·프리미엄 부여 포함 — 커밋 금지)
|
||||
scripts/seed-test-*.sql
|
||||
scripts/cleanup-test-*.sql
|
||||
|
||||
# 환경변수 / 비밀 정보 (실제 .env 는 커밋 금지, .env.example 만 추적)
|
||||
.env
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
+5
-1
@@ -4,7 +4,7 @@ plugins {
|
||||
id 'io.spring.dependency-management' version '1.1.7'
|
||||
}
|
||||
|
||||
group = 'com.example'
|
||||
group = 'com.sb'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
|
||||
java {
|
||||
@@ -22,6 +22,10 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5'
|
||||
// 비밀번호 BCrypt 해싱 (전체 Spring Security 미도입, crypto 모듈만 사용)
|
||||
implementation 'org.springframework.security:spring-security-crypto'
|
||||
// Apple 로그인 ID 토큰(JWT) 서명 검증 — Apple JWKS(공개키) 기반
|
||||
implementation 'com.nimbusds:nimbus-jose-jwt:9.40'
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 백엔드 배포 (SSH + systemd)
|
||||
|
||||
`main` 푸시 시 [.gitea/workflows/deploy.yaml](../.gitea/workflows/deploy.yaml) 가
|
||||
jar 를 빌드해 서버로 전송하고 systemd 서비스를 재시작합니다.
|
||||
|
||||
## 1. Gitea 저장소 시크릿 (Settings → Actions → Secrets)
|
||||
| 시크릿 | 설명 | 예시 |
|
||||
|---|---|---|
|
||||
| `DEPLOY_HOST` | 서버 호스트/IP | `10.0.0.5` |
|
||||
| `DEPLOY_USER` | SSH 계정 | `deploy` |
|
||||
| `DEPLOY_SSH_KEY` | SSH 개인키(PEM 전체) | `-----BEGIN OPENSSH PRIVATE KEY-----...` |
|
||||
| `DEPLOY_PATH` | jar 배치 경로 | `/opt/sb-backend` |
|
||||
| `DEPLOY_PORT` | SSH 포트(선택) | `22` |
|
||||
| `SERVICE_NAME` | systemd 유닛명(선택) | `sb-backend` |
|
||||
|
||||
## 2. 서버 1회 준비
|
||||
```bash
|
||||
# 자바 17, 배포 계정/디렉터리
|
||||
sudo apt-get install -y openjdk-17-jre-headless
|
||||
sudo useradd -m -s /bin/bash deploy # 이미 있으면 생략
|
||||
sudo mkdir -p /opt/sb-backend && sudo chown deploy:deploy /opt/sb-backend
|
||||
|
||||
# 러너가 보낸 공개키를 deploy 계정에 등록
|
||||
sudo -u deploy mkdir -p /home/deploy/.ssh
|
||||
echo "<배포용 공개키>" | sudo -u deploy tee -a /home/deploy/.ssh/authorized_keys
|
||||
|
||||
# 운영 환경변수 (.env, git 미추적) — DB/Redis 접속정보
|
||||
sudo -u deploy tee /opt/sb-backend/.env >/dev/null <<'EOF'
|
||||
DB_URL=jdbc:mariadb://localhost:3306/sb_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
|
||||
DB_USERNAME=sb
|
||||
DB_PASSWORD=changeme
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
EOF
|
||||
|
||||
# systemd 유닛 설치
|
||||
sudo cp deploy/sb-backend.service /etc/systemd/system/sb-backend.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now sb-backend
|
||||
|
||||
# deploy 계정에 해당 서비스 재시작 무비밀번호 sudo 허용
|
||||
echo 'deploy ALL=(root) NOPASSWD: /bin/systemctl restart sb-backend, /bin/systemctl is-active sb-backend' \
|
||||
| sudo tee /etc/sudoers.d/sb-backend
|
||||
```
|
||||
|
||||
## 3. 동작
|
||||
- main 푸시 → CI(빌드·테스트) + Deploy(빌드·전송·재시작) 가 각각 실행
|
||||
- 수동 배포: 저장소 Actions → Deploy → Run workflow
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=SB Backend (Spring Boot)
|
||||
After=network.target mariadb.service redis-server.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=deploy
|
||||
WorkingDirectory=/opt/sb-backend
|
||||
# WorkingDirectory 의 .env(properties 형식)를 Spring 이 자동 로드 (application.yml 의 config.import)
|
||||
# 또는 아래처럼 systemd 로 주입해도 됨:
|
||||
# EnvironmentFile=/opt/sb-backend/.env
|
||||
ExecStart=/usr/bin/java -jar /opt/sb-backend/app.jar
|
||||
SuccessExitStatus=143
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# 돈돼지 가계부 — 백엔드 (sb_bt)
|
||||
|
||||
Spring Boot 기반의 가계부·자산·게시판 REST API 서버. (구 Slim Budget)
|
||||
|
||||
## 기술 스택
|
||||
- **Spring Boot 3.5** / **Java 17**
|
||||
- **MyBatis** (XML 매퍼) + **MariaDB**
|
||||
- **Redis** (세션 저장 — DB 이중 저장으로 유실 대비)
|
||||
- **spring-security-crypto** (BCrypt 비밀번호 해시)
|
||||
- Lombok, Bean Validation
|
||||
|
||||
## 패키지 구조
|
||||
```
|
||||
com.sb.web
|
||||
├── account # 가계부 (controller / service / mapper / domain / dto)
|
||||
│ └ invest·budget·category·recurring·quick·ocr·fx·backup 포함
|
||||
├── auth # 인증 (로그인/세션/구글로그인, AuthInterceptor, 포인트)
|
||||
├── billing # 인앱결제·구독(Google Play Billing 검증, RTDN)
|
||||
├── board # 게시판 (글/댓글/추천/공지/이미지)
|
||||
├── user # 사용자
|
||||
├── admin # 관리자 (기본분류·게시판/가입 설정·회원 관리)
|
||||
└── common # 공통 (예외, 응답, 헬스체크, 설정)
|
||||
```
|
||||
|
||||
## 스키마 자동 초기화
|
||||
`spring.sql.init` 로 기동 시 적용. 기본 `mode: never`, 환경변수 **`SQL_INIT_MODE=always`** 로 켜면 실행(멱등 — `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`):
|
||||
```
|
||||
schema-locations: db/member.sql, db/board.sql, db/account.sql, db/dev-seed.sql
|
||||
continue-on-error: true
|
||||
```
|
||||
주요 테이블:
|
||||
- **회원/인증**: `member`, `auth_session`(세션 DB 백업), `point_history`, `app_setting`(가입 허용 등), `iap_purchase`(인앱결제)
|
||||
- **가계부**: `wallet`, `account_entry`, `account_category`(`parent_id` 대/소분류), `account_tag`, `account_entry_tag`, `budget`, `budget_income`, `recurring`, `quick_entry`, `invest_holding`, `invest_trade`
|
||||
- **게시판**: `post`, `comment`, `post_vote`, `comment_vote`, `post_tag`, `tag`, `tag_category`, `tag_category_board`, `board_image`, `report`
|
||||
- **기타**: `default_category`(기본 분류 템플릿)
|
||||
|
||||
## 인증 / 보안
|
||||
- 로그인/구글로그인 시 토큰 발급, 이후 `Authorization: Bearer <token>` 로 인증
|
||||
- **구글 로그인**: `POST /api/auth/google` 로 ID 토큰 수신 → **tokeninfo 검증(aud=웹 클라이언트 ID)** 후 세션 발급. 검증된 동일 이메일이면 기존 계정에 `member.google_id` 로 연결
|
||||
- `AuthInterceptor` 가 현재 사용자(`SessionUser`)를 요청 속성(`CURRENT_MEMBER`)으로 주입
|
||||
- **세션 이중 저장**: Redis 유실·재시작 시 `auth_session`(DB)에서 복원·재수화
|
||||
- **무차별 대입 방지**: 실패 시도만 카운트 — 로그인 IP당 10회/10분, 비밀번호 재인증 회원당 5회/10분 초과 시 429
|
||||
- **봇 가입 차단**: 허니팟 + IP 레이트리밋(Redis)
|
||||
- **모든 가계부 데이터는 `member_id`(소유자)로 격리** — 본인 데이터만 조회/수정
|
||||
- DB/Redis 자격증명·`ACCOUNT_CRYPTO_KEY`(계좌번호 AES-256-GCM)·`GOOGLE_CLIENT_ID` 등은 `.env`(gitignore), `.env.example` 템플릿 제공
|
||||
|
||||
## REST API 개요
|
||||
|
||||
### 인증 · 사용자 · 관리자
|
||||
- `/api/auth` — `login`·`signup`·`logout`·`google`·`me`(GET/DELETE) / `password`·`verify-password`·`profile`·`profile-image` / `points`·`point-history` / `signup-enabled`·`google-client-id`
|
||||
- `/api/users` — 사용자 CRUD
|
||||
- `/api/admin` — `signup-setting`(가입 허용 토글), `default-categories`(기본 분류), `members`(회원 목록·`role`·`plan`·`status` 변경·삭제), 태그 카테고리
|
||||
- `/api/billing` — `products`·`subscription`·`google/verify`·`cancel`·`resume`·`restore`·`google/rtdn`(정기 알림)
|
||||
- `/api/board` — 글/댓글 CRUD, `posts/{id}/vote`(추천), `notice`/`unnotice`(공지), `block`/`unblock`(열람제한), `my/posts`·`my/comments`, `tag-groups`, `recommenders`
|
||||
- `/api/board/images` (업로드) · `/api/images/{id}` (공개 조회) — 게시판 이미지(DB 저장, 절대 URL 반환)
|
||||
|
||||
### 가계부 `/api/account`
|
||||
| 메서드·경로 | 설명 |
|
||||
|---|---|
|
||||
| `GET/POST/PUT/DELETE /entries` | 내역 CRUD. 조회 필터: `year,month,type,category,walletId,keyword,tagId` |
|
||||
| `POST /entries/notification` `POST /entries/parse` | 카드/은행 알림 자동인식 등록 · 자연어 파싱 |
|
||||
| `GET /entries/pending/count` `POST /entries/{id}/confirm` | 확인 필요(pending) 건수 · 확정 |
|
||||
| `GET /summary` `GET /stats` | 월 요약 / 기간 버킷(일·주·월·년) 수입·지출 |
|
||||
| `GET /category-stats` | 분류별 합계 (파이차트, 대/소분류) |
|
||||
| `GET/POST/PUT/DELETE /wallets` (+`/reorder`) | 계좌(BANK·CASH·CARD·LOAN·INVEST) |
|
||||
| `GET /networth` `GET /networth/trend` | 순자산 / 순자산 추이(`unit=WEEK&weeks=N` 주간 지원) |
|
||||
| `GET /wallets/{id}/entries` `POST /repayment` | 계좌별 내역 / 상환·납부(원금=이체·이자=지출·연회비=지출) |
|
||||
| `GET/POST/PUT/DELETE /tags` (+`/reorder`) | 사용자별 가계부 태그 |
|
||||
| `/categories` (+`/reorder`,`/import`) | 분류 CRUD, 순서변경, 기존 내역에서 가져오기 |
|
||||
| `/budgets` (+`/income`,`/copy`,`/status`,`/period`) | 월별 예산 CRUD, 수입, 전월 복사, 진행률, 기간별 |
|
||||
| `/recurrings` (+`/run`) | 고정지출 규칙, 밀린 회차 자동 생성 |
|
||||
| `/quick-entries` | 자주 쓰는 내역 |
|
||||
| `/invest/**` | 투자 포트폴리오(아래) |
|
||||
| `/ocr/receipt` | 영수증 OCR (Google Vision) |
|
||||
| `POST /restore` | 백업 데이터 복구 (엑셀·JSON) |
|
||||
|
||||
> 환율: `GET /api/fx/rate` — 외화 입력 시 환율 자동조회(원화 환산).
|
||||
|
||||
### 잔액 · 순자산 모델
|
||||
- 부호 모델: 자산(+) / 부채(−). 현금(CASH)은 은행(BANK)과 동일하게 자산 합산
|
||||
- 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금 (`WalletMapper.findBalances`)
|
||||
- 순자산 = Σ자산 − Σ부채. 투자계좌는 **예수금 + 주식 평가금액**으로 반영
|
||||
- 순자산 추이: 현금흐름 누적(이체는 상쇄) + 투자 손익은 현재 시점에 가산. 주간(월요일 기준) 집계 지원
|
||||
|
||||
### 고정지출 (recurring)
|
||||
- 빈도(DAILY/WEEKLY/MONTHLY/YEARLY) + 일/요일/월 지정
|
||||
- `last_run_date` 커서로 **중복 없이** 밀린 회차를 따라잡아 `account_entry` 생성. 자동 생성분은 **'확인 필요'** 상태로 만들어 실제 처리 확인 후 확정
|
||||
|
||||
### 투자 포트폴리오 (`/api/account/invest`) — 종목 단위
|
||||
| 경로 | 설명 |
|
||||
|---|---|
|
||||
| `GET/POST/PUT/DELETE /holdings` | 보유 종목(종목명·코드·현재가). 집계: 수량·평단·평가금액·평가손익·수익률·실현손익 |
|
||||
| `GET/POST /holdings/{id}/trades`, `PUT/DELETE /trades/{id}` | 매수/매도 이력 (수정 시 시점별 시뮬레이션으로 보유수량 음수 방지) |
|
||||
| `POST /holdings/refresh-prices`, `POST /refresh-prices` | 종목코드로 현재가 자동 갱신(네이버 금융) |
|
||||
- 매매이력(시간순)으로 **이동평균 평단**·실현손익·평가손익 산출. 매도수량이 보유수량 초과 시 400
|
||||
- 투자계좌 가치 = 예수금(현금 ± 매매) + 주식평가, 순자산/추이에 연동
|
||||
- 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외)
|
||||
- ℹ️ 백엔드는 종목 단위 API를 유지하나, **현재 프론트 UI 는 투자금+평가액만 입력하는 단순형**으로 노출
|
||||
|
||||
## 개발/실행
|
||||
```bash
|
||||
cp .env.example .env # DB/Redis 자격증명 입력 (SQL_INIT_MODE=always 로 스키마 자동생성)
|
||||
./gradlew bootRun # http://localhost:8080
|
||||
./gradlew test # 단위 + @WebMvcTest 통합 테스트
|
||||
```
|
||||
|
||||
## 배포
|
||||
- `main` 푸시 → Gitea Actions 가 CI(빌드·테스트) + Deploy(jar 전송·systemd 재시작) 실행. 테스트 실패 시 배포 중단
|
||||
- 상세: [deploy/README.md](../deploy/README.md)
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.example.sb_bt.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* Vue 개발 서버(기본 5173)에서의 호출을 허용하기 위한 CORS 설정.
|
||||
* 운영 환경에서는 allowedOrigins 를 실제 도메인으로 제한하세요.
|
||||
*/
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins("http://localhost:5173")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -1,11 +1,13 @@
|
||||
package com.example.sb_bt;
|
||||
package com.sb.web;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@MapperScan("com.example.sb_bt.mapper")
|
||||
@EnableScheduling
|
||||
@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper", "com.sb.web.point.mapper", "com.sb.web.billing.mapper"})
|
||||
public class SbBtApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
@@ -0,0 +1,271 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.AccountEntryRequest;
|
||||
import com.sb.web.account.dto.AccountEntryResponse;
|
||||
import com.sb.web.account.dto.AccountSummary;
|
||||
import com.sb.web.account.dto.ParsedEntryResponse;
|
||||
import com.sb.web.account.dto.AccountTagRequest;
|
||||
import com.sb.web.account.dto.AccountTagResponse;
|
||||
import com.sb.web.account.dto.CategoryStat;
|
||||
import com.sb.web.account.dto.ConfirmRequest;
|
||||
import com.sb.web.account.dto.NotificationRequest;
|
||||
import com.sb.web.account.dto.TagReorderRequest;
|
||||
import com.sb.web.account.dto.NetWorthPoint;
|
||||
import com.sb.web.account.dto.NetWorthResponse;
|
||||
import com.sb.web.account.dto.PeriodStat;
|
||||
import com.sb.web.account.dto.RepaymentRequest;
|
||||
import com.sb.web.account.dto.WalletReorderRequest;
|
||||
import com.sb.web.account.dto.WalletRequest;
|
||||
import com.sb.web.account.dto.WalletResponse;
|
||||
import com.sb.web.account.service.AccountService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 가계부 API. (/api/account/** — 로그인 필요, 본인 데이터만 접근)
|
||||
* GET /entries?year=&month= 내역 목록(본인)
|
||||
* GET /summary?year=&month= 요약(수입/지출/잔액)
|
||||
* POST /entries 등록
|
||||
* PUT /entries/{id} 수정(본인)
|
||||
* DELETE /entries/{id} 삭제(본인)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account")
|
||||
@RequiredArgsConstructor
|
||||
public class AccountController {
|
||||
|
||||
private final AccountService accountService;
|
||||
|
||||
/* ===== 계좌/카드 (사용자별) ===== */
|
||||
|
||||
@GetMapping("/wallets")
|
||||
public List<WalletResponse> wallets(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.listWallets(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/wallets")
|
||||
public ResponseEntity<WalletResponse> createWallet(
|
||||
@Valid @RequestBody WalletRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
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}")
|
||||
public WalletResponse updateWallet(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody WalletRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.updateWallet(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/wallets/{id}")
|
||||
public ResponseEntity<Void> deleteWallet(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
accountService.deleteWallet(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 계좌/카드 순서 변경 (드래그앤드랍) */
|
||||
@PutMapping("/wallets/reorder")
|
||||
public List<WalletResponse> reorderWallets(
|
||||
@RequestBody WalletReorderRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.reorderWallets(current.getId(), req.getType(), req.getIds());
|
||||
}
|
||||
|
||||
@GetMapping("/networth")
|
||||
public NetWorthResponse netWorth(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.netWorth(current.getId());
|
||||
}
|
||||
|
||||
/** 특정 계좌의 연관 내역 */
|
||||
@GetMapping("/wallets/{id}/entries")
|
||||
public List<AccountEntryResponse> walletEntries(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.listEntriesByWallet(id, current.getId());
|
||||
}
|
||||
|
||||
/** 대출/카드 상환·납부 (원금=이체, 이자=지출 자동 분리) */
|
||||
@PostMapping("/repayment")
|
||||
public ResponseEntity<Void> repay(
|
||||
@Valid @RequestBody RepaymentRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
accountService.repay(req, current.getId());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).build();
|
||||
}
|
||||
|
||||
/* ===== 가계부 태그 (사용자별) ===== */
|
||||
|
||||
@GetMapping("/tags")
|
||||
public List<AccountTagResponse> tags(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.listTags(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/tags")
|
||||
public ResponseEntity<AccountTagResponse> createTag(
|
||||
@Valid @RequestBody AccountTagRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(accountService.createTag(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/tags/{id}")
|
||||
public AccountTagResponse updateTag(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody AccountTagRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.updateTag(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/tags/{id}")
|
||||
public ResponseEntity<Void> deleteTag(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
accountService.deleteTag(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 태그 순서 변경 */
|
||||
@PutMapping("/tags/reorder")
|
||||
public List<AccountTagResponse> reorderTags(
|
||||
@RequestBody TagReorderRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.reorderTags(current.getId(), req.getIds());
|
||||
}
|
||||
|
||||
@GetMapping("/entries")
|
||||
public List<AccountEntryResponse> list(
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) Long walletId,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Long tagId,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.list(current.getId(), year, month, type, category, walletId, keyword, tagId);
|
||||
}
|
||||
|
||||
/** 분류(카테고리)별 합계 — 파이차트용 */
|
||||
@GetMapping("/category-stats")
|
||||
public List<CategoryStat> categoryStats(
|
||||
@RequestParam(defaultValue = "EXPENSE") String type,
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.categoryStats(current.getId(), type, year, month);
|
||||
}
|
||||
|
||||
/** 순자산 추이 (unit=WEEK 면 주별, weeks 개수 / 그 외 월별, months 개수) */
|
||||
@GetMapping("/networth/trend")
|
||||
public List<NetWorthPoint> netWorthTrend(
|
||||
@RequestParam(required = false) Integer months,
|
||||
@RequestParam(required = false) Integer weeks,
|
||||
@RequestParam(defaultValue = "MONTH") String unit,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
if ("WEEK".equalsIgnoreCase(unit)) {
|
||||
return accountService.netWorthTrendWeekly(current.getId(), weeks);
|
||||
}
|
||||
return accountService.netWorthTrend(current.getId(), months);
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
public List<PeriodStat> stats(
|
||||
@RequestParam(defaultValue = "MONTH") String unit,
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.stats(current.getId(), unit, year, month);
|
||||
}
|
||||
|
||||
@GetMapping("/summary")
|
||||
public AccountSummary summary(
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.summary(current.getId(), year, month);
|
||||
}
|
||||
|
||||
@PostMapping("/entries")
|
||||
public ResponseEntity<AccountEntryResponse> create(
|
||||
@Valid @RequestBody AccountEntryRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(accountService.create(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/entries/{id}")
|
||||
public AccountEntryResponse update(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody AccountEntryRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.update(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/entries/{id}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
accountService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/* ===== 카드 결제 알림 자동인식 ===== */
|
||||
|
||||
/** 미확인(확인 필요) 내역 개수 */
|
||||
@GetMapping("/entries/pending/count")
|
||||
public Map<String, Integer> pendingCount(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return Map.of("count", accountService.countPending(current.getId()));
|
||||
}
|
||||
|
||||
/** 카드 결제 푸시 알림 → 미확인 지출 내역 생성 (네이티브 알림 리스너가 호출). 생성 안 되면 204 */
|
||||
@PostMapping("/entries/notification")
|
||||
public ResponseEntity<AccountEntryResponse> fromNotification(
|
||||
@RequestBody NotificationRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
AccountEntryResponse created = accountService.createFromNotification(req, current.getId());
|
||||
if (created == null) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||
}
|
||||
|
||||
/** 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움. */
|
||||
@PostMapping("/entries/parse")
|
||||
public ParsedEntryResponse parseText(
|
||||
@RequestBody NotificationRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.parseText(req);
|
||||
}
|
||||
|
||||
/** 통계 화면 집계 → AI 재무 코멘트. PREMIUM 무제한, FREE 는 크레딧 차감. */
|
||||
@PostMapping("/ai-comment")
|
||||
public com.sb.web.account.dto.AiCommentResponse aiComment(
|
||||
@RequestBody com.sb.web.account.dto.AiCommentRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.aiComment(req, current);
|
||||
}
|
||||
|
||||
/** 미확인 내역 확정 (분류/계좌 보정) */
|
||||
@PostMapping("/entries/{id}/confirm")
|
||||
public AccountEntryResponse confirm(
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) ConfirmRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.confirm(id, req, current.getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.RestoreRequest;
|
||||
import com.sb.web.account.service.BackupService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 데이터 가져오기(복구) API. (로그인 필요 — /api/account/** 보호)
|
||||
* POST /api/account/restore 엑셀 백업으로 전체 데이터 복구(기존 데이터 덮어씀)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account")
|
||||
@RequiredArgsConstructor
|
||||
public class BackupController {
|
||||
|
||||
private final BackupService backupService;
|
||||
|
||||
@PostMapping("/restore")
|
||||
public ResponseEntity<Void> restore(
|
||||
@RequestBody RestoreRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
backupService.restore(current.getId(), req);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/all")
|
||||
public ResponseEntity<Void> deleteAll(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
backupService.deleteAll(current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.BudgetOnetimeRequest;
|
||||
import com.sb.web.account.dto.BudgetOnetimeResponse;
|
||||
import com.sb.web.account.dto.BudgetPeriodStat;
|
||||
import com.sb.web.account.dto.BudgetRequest;
|
||||
import com.sb.web.account.dto.BudgetResponse;
|
||||
import com.sb.web.account.dto.BudgetStatus;
|
||||
import com.sb.web.account.dto.ExpectedIncomeRequest;
|
||||
import com.sb.web.account.service.AccountSettingService;
|
||||
import com.sb.web.account.service.BudgetOnetimeService;
|
||||
import com.sb.web.account.service.BudgetService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 예산 API. (/api/account/budgets — 로그인 필요, 본인 데이터만)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account/budgets")
|
||||
@RequiredArgsConstructor
|
||||
public class BudgetController {
|
||||
|
||||
private final BudgetService budgetService;
|
||||
private final BudgetOnetimeService onetimeService;
|
||||
private final AccountSettingService settingService;
|
||||
|
||||
/** 월 예상 수입 조회 (연·월별) */
|
||||
@GetMapping("/income")
|
||||
public ExpectedIncomeRequest getIncome(
|
||||
@RequestParam int year,
|
||||
@RequestParam int month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return new ExpectedIncomeRequest(settingService.getExpectedIncome(current.getId(), year, month));
|
||||
}
|
||||
|
||||
/** 월 예상 수입 설정 (연·월별) */
|
||||
@PutMapping("/income")
|
||||
public ExpectedIncomeRequest setIncome(
|
||||
@RequestParam int year,
|
||||
@RequestParam int month,
|
||||
@Valid @RequestBody ExpectedIncomeRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
settingService.setExpectedIncome(current.getId(), year, month, req.getExpectedIncome());
|
||||
return req;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<BudgetResponse> list(
|
||||
@RequestParam int year,
|
||||
@RequestParam int month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return budgetService.list(current.getId(), year, month);
|
||||
}
|
||||
|
||||
/** 다른 월의 예산을 이 달로 복사(전월 복사 등). 같은 분류는 덮어씀 */
|
||||
@PostMapping("/copy")
|
||||
public List<BudgetResponse> copy(
|
||||
@RequestParam int fromYear,
|
||||
@RequestParam int fromMonth,
|
||||
@RequestParam int toYear,
|
||||
@RequestParam int toMonth,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
budgetService.copy(current.getId(), fromYear, fromMonth, toYear, toMonth);
|
||||
return budgetService.list(current.getId(), toYear, toMonth);
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
public List<BudgetStatus> status(
|
||||
@RequestParam int year,
|
||||
@RequestParam int month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return budgetService.status(current.getId(), year, month);
|
||||
}
|
||||
|
||||
/** 기간(일/주/월/년)별 예산 대비 지출 (대시보드 막대그래프) */
|
||||
@GetMapping("/period")
|
||||
public List<BudgetPeriodStat> period(
|
||||
@RequestParam(defaultValue = "MONTH") String unit,
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return budgetService.periodStats(current.getId(), unit, year, month);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<BudgetResponse> create(
|
||||
@RequestParam int year,
|
||||
@RequestParam int month,
|
||||
@Valid @RequestBody BudgetRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(budgetService.create(req, current.getId(), year, month));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public BudgetResponse update(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody BudgetRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return budgetService.update(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
budgetService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ===== 1회성 예산 =====
|
||||
|
||||
@GetMapping("/onetime")
|
||||
public List<BudgetOnetimeResponse> onetimeList(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return onetimeService.list(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/onetime")
|
||||
public ResponseEntity<BudgetOnetimeResponse> onetimeCreate(
|
||||
@Valid @RequestBody BudgetOnetimeRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(onetimeService.create(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/onetime/{id}")
|
||||
public BudgetOnetimeResponse onetimeUpdate(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody BudgetOnetimeRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return onetimeService.update(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/onetime/{id}")
|
||||
public ResponseEntity<Void> onetimeDelete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
onetimeService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.CategoryReorderRequest;
|
||||
import com.sb.web.account.dto.CategoryRequest;
|
||||
import com.sb.web.account.dto.CategoryResponse;
|
||||
import com.sb.web.account.service.CategoryService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 분류(카테고리) API. (/api/account/categories — 로그인 필요, 본인 데이터만)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account/categories")
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryController {
|
||||
|
||||
private final CategoryService categoryService;
|
||||
|
||||
@GetMapping
|
||||
public List<CategoryResponse> list(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return categoryService.list(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<CategoryResponse> create(
|
||||
@Valid @RequestBody CategoryRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(categoryService.create(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public CategoryResponse update(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody CategoryRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return categoryService.update(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
categoryService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 드래그앤드랍 순서 변경 */
|
||||
@PutMapping("/reorder")
|
||||
public List<CategoryResponse> reorder(
|
||||
@RequestBody CategoryReorderRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return categoryService.reorder(current.getId(), req.getType(), req.getIds());
|
||||
}
|
||||
|
||||
/** 관리자가 정의한 기본 분류를 내 분류로 불러오기 */
|
||||
@PostMapping("/import")
|
||||
public List<CategoryResponse> importDefaults(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return categoryService.importDefaults(current.getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.service.FxService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 환율 조회 API. 외화 결제 입력 시 통화→원화 환율 자동 채움용.
|
||||
* GET /api/fx/rate?from=USD&to=KRW → { from, to, rate }
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/fx")
|
||||
public class FxController {
|
||||
|
||||
private final FxService fxService;
|
||||
|
||||
public FxController(FxService fxService) {
|
||||
this.fxService = fxService;
|
||||
}
|
||||
|
||||
@GetMapping("/rate")
|
||||
public Map<String, Object> rate(@RequestParam String from,
|
||||
@RequestParam(defaultValue = "KRW") String to) {
|
||||
BigDecimal rate = fxService.getRate(from, to);
|
||||
Instant updatedAt = fxService.getCachedAt(from);
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
res.put("from", from == null ? null : from.toUpperCase());
|
||||
res.put("to", to == null ? null : to.toUpperCase());
|
||||
res.put("rate", rate); // 조회 실패 시 null
|
||||
res.put("updatedAt", updatedAt != null ? updatedAt.toString() : null);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.HoldingRequest;
|
||||
import com.sb.web.account.dto.HoldingResponse;
|
||||
import com.sb.web.account.dto.TradeRequest;
|
||||
import com.sb.web.account.dto.TradeResponse;
|
||||
import com.sb.web.account.service.InvestService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 투자 포트폴리오 API (C). 로그인 필요, 본인 데이터만.
|
||||
* GET /api/account/invest/holdings?walletId= 보유종목(집계 포함)
|
||||
* POST /api/account/invest/holdings 종목 추가
|
||||
* PUT /api/account/invest/holdings/{id} 종목 수정(현재가 등)
|
||||
* DELETE /api/account/invest/holdings/{id} 종목 삭제(매매이력 포함)
|
||||
* GET /api/account/invest/holdings/{id}/trades 매매이력
|
||||
* POST /api/account/invest/holdings/{id}/trades 매수/매도
|
||||
* PUT /api/account/invest/trades/{id} 매매 수정
|
||||
* DELETE /api/account/invest/trades/{id} 매매 삭제
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account/invest")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestController {
|
||||
|
||||
private final InvestService investService;
|
||||
|
||||
@GetMapping("/holdings")
|
||||
public List<HoldingResponse> holdings(
|
||||
@RequestParam Long walletId,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return investService.listHoldings(current.getId(), walletId);
|
||||
}
|
||||
|
||||
@PostMapping("/holdings")
|
||||
public ResponseEntity<HoldingResponse> createHolding(
|
||||
@Valid @RequestBody HoldingRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(investService.createHolding(req, current.getId()));
|
||||
}
|
||||
|
||||
/** 종목코드로 현재가 시세를 일괄 갱신 */
|
||||
@PostMapping("/holdings/refresh-prices")
|
||||
public List<HoldingResponse> refreshPrices(
|
||||
@RequestParam Long walletId,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return investService.refreshPrices(current.getId(), walletId);
|
||||
}
|
||||
|
||||
/** 모든 투자계좌 보유종목 현재가 일괄 갱신(계좌 목록 평가액 반영용) */
|
||||
@PostMapping("/refresh-prices")
|
||||
public ResponseEntity<Void> refreshAllPrices(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
investService.refreshAllPrices(current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/holdings/{id}")
|
||||
public HoldingResponse updateHolding(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody HoldingRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return investService.updateHolding(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/holdings/{id}")
|
||||
public ResponseEntity<Void> deleteHolding(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
investService.deleteHolding(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/holdings/{id}/trades")
|
||||
public List<TradeResponse> trades(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return investService.listTrades(id, current.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/holdings/{id}/trades")
|
||||
public ResponseEntity<TradeResponse> addTrade(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody TradeRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(investService.addTrade(id, req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/trades/{id}")
|
||||
public TradeResponse updateTrade(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody TradeRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return investService.updateTrade(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/trades/{id}")
|
||||
public ResponseEntity<Void> deleteTrade(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
investService.deleteTrade(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.ParsedEntryResponse;
|
||||
import com.sb.web.account.service.AiEntryParser;
|
||||
import com.sb.web.account.service.VisionOcrService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 영수증 OCR API (/api/account/ocr/** — 로그인 필요).
|
||||
* - /receipt : Google Vision 텍스트 추출(프론트 규칙 파서용, 폴백)
|
||||
* - /receipt-ai: Claude Vision 으로 영수증을 구조화(금액·상호·날짜·분류·계좌). AI 미설정/실패 시 recognized=false.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account/ocr")
|
||||
@RequiredArgsConstructor
|
||||
public class OcrController {
|
||||
|
||||
private final VisionOcrService visionOcrService;
|
||||
private final AiEntryParser aiEntryParser;
|
||||
|
||||
@PostMapping("/receipt")
|
||||
public Map<String, String> receipt(
|
||||
@RequestParam("image") MultipartFile image,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) throws IOException {
|
||||
if (image == null || image.isEmpty()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "영수증 이미지를 첨부하세요.");
|
||||
}
|
||||
String text = visionOcrService.recognize(image.getBytes());
|
||||
return Map.of("text", text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영수증 이미지 → AI 구조화. AI 미설정/실패/미인식이면 recognized=false 로 반환(프론트가 /receipt 폴백).
|
||||
* 분류·계좌 후보는 멀티파트 파트 수 제한(Tomcat 기본 10개)을 피하려 '줄바꿈'으로 합친 단일 필드로 받는다.
|
||||
*/
|
||||
@PostMapping("/receipt-ai")
|
||||
public ParsedEntryResponse receiptAi(
|
||||
@RequestParam("image") MultipartFile image,
|
||||
@RequestParam(value = "categoryHints", required = false) String categoryHints,
|
||||
@RequestParam(value = "walletHints", required = false) String walletHints,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) throws IOException {
|
||||
if (image == null || image.isEmpty()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "영수증 이미지를 첨부하세요.");
|
||||
}
|
||||
return aiEntryParser
|
||||
.parseReceipt(image.getBytes(), image.getContentType(), LocalDate.now(),
|
||||
splitLines(categoryHints), splitLines(walletHints))
|
||||
.orElseGet(() -> ParsedEntryResponse.builder().recognized(false).build());
|
||||
}
|
||||
|
||||
/** 줄바꿈으로 합쳐 온 후보 문자열 → 리스트(공백 항목 제거). null/빈 값이면 빈 리스트. */
|
||||
private static List<String> splitLines(String joined) {
|
||||
if (joined == null || joined.isBlank()) return List.of();
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String s : joined.split("\n")) {
|
||||
String t = s.trim();
|
||||
if (!t.isEmpty()) out.add(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.QuickEntryRequest;
|
||||
import com.sb.web.account.dto.QuickEntryResponse;
|
||||
import com.sb.web.account.service.QuickEntryService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 자주 쓰는 내역(빠른 등록 템플릿) API. (/api/account/quick-entries — 로그인 필요, 본인 데이터만)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account/quick-entries")
|
||||
@RequiredArgsConstructor
|
||||
public class QuickEntryController {
|
||||
|
||||
private final QuickEntryService service;
|
||||
|
||||
@GetMapping
|
||||
public List<QuickEntryResponse> list(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return service.list(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<QuickEntryResponse> create(
|
||||
@Valid @RequestBody QuickEntryRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req, current.getId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
service.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.RecurringRequest;
|
||||
import com.sb.web.account.dto.RecurringResponse;
|
||||
import com.sb.web.account.service.RecurringService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 정기/반복 거래 API. (/api/account/recurrings — 로그인 필요, 본인 데이터만)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account/recurrings")
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringController {
|
||||
|
||||
private final RecurringService recurringService;
|
||||
|
||||
@GetMapping
|
||||
public List<RecurringResponse> list(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return recurringService.list(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<RecurringResponse> create(
|
||||
@Valid @RequestBody RecurringRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boolean premium = "ADMIN".equals(current.getRole()) || "PREMIUM".equals(current.getPlan());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(recurringService.create(req, current.getId(), premium));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public RecurringResponse update(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody RecurringRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return recurringService.update(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
recurringService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 밀린 회차 자동 생성 → 생성 건수 반환 */
|
||||
@PostMapping("/run")
|
||||
public Map<String, Integer> run(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return Map.of("generated", recurringService.run(current.getId()));
|
||||
}
|
||||
}
|
||||
@@ -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,27 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 분류(카테고리) — 사용자별, 수입(INCOME)/지출(EXPENSE).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AccountCategory implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String type; // INCOME / EXPENSE
|
||||
private String name;
|
||||
private Long parentId; // 대분류 id (NULL=대분류, 값=소분류)
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 가계부 항목. account_entry 테이블과 매핑. (소유자 member_id 격리)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AccountEntry implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private LocalDate entryDate;
|
||||
private String type; // INCOME / EXPENSE
|
||||
private String category;
|
||||
private Long amount; // 원(양수)
|
||||
private String memo;
|
||||
private Long walletId; // 발생/출금 계좌(wallet.id)
|
||||
private String walletName; // 조인 표시용
|
||||
private Long toWalletId; // 이체 입금 계좌
|
||||
private String toWalletName;
|
||||
private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null)
|
||||
private String currency; // 외화 통화코드(USD 등). null/KRW = 원화
|
||||
private BigDecimal originalAmount; // 외화 원본 금액
|
||||
private BigDecimal exchangeRate; // 적용 환율(외화 1단위→원화)
|
||||
private Boolean pending; // 확인 필요(미확정) 여부 — 알림 자동인식 건
|
||||
private String notifKey; // 알림 자동인식 중복방지 키
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 가계부 태그 (사용자별). account_tag 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AccountTag implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String name;
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 예산 (사용자별, 카테고리별). budget 테이블과 매핑.
|
||||
* fixed=true: base_unit/base_amount 로 일수기준 환산. false: daily/weekly/monthly/yearly 직접.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Budget implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String category;
|
||||
private Boolean fixed;
|
||||
private String baseUnit; // DAY / WEEK / MONTH
|
||||
private Long baseAmount;
|
||||
private Long daily;
|
||||
private Long weekly;
|
||||
private Long monthly;
|
||||
private Long yearly;
|
||||
private Integer year; // 예산 연도(월별 예산)
|
||||
private Integer month; // 예산 월(1~12)
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 1회성 예산 — 특정 기간(start_date~end_date)에 단발 예산을 설정.
|
||||
* category=null 이면 전체 지출 합산, 값 있으면 해당 카테고리만.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BudgetOnetime {
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String title;
|
||||
private String category; // null = 전체
|
||||
private Long amount;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 기본(디폴트) 분류 — 전체 사용자 공용 템플릿(관리자 관리). 사용자는 자기 분류로 복사해 사용.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DefaultCategory implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private String type; // INCOME / EXPENSE
|
||||
private String name;
|
||||
private Long parentId; // 대분류 id (NULL=대분류, 값=소분류)
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 투자 보유 종목 (INVEST 지갑별, 사용자별). invest_holding 테이블과 매핑.
|
||||
* 수량/평단/평가손익은 매매이력(invest_trade)으로 서비스에서 산출한다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InvestHolding {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private Long walletId;
|
||||
private String name; // 종목명
|
||||
private String ticker; // 종목코드(선택)
|
||||
private Long currentPrice; // 현재가(원, 수동)
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 투자 매매 이력(매수/매도). invest_trade 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InvestTrade {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private Long holdingId;
|
||||
private String tradeType; // BUY / SELL
|
||||
private LocalDate tradeDate;
|
||||
private BigDecimal quantity; // 수량(주, 소수점 매매 지원)
|
||||
private Long price; // 단가(원)
|
||||
private Long fee; // 수수료/세금(원)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 자주 쓰는 내역(빠른 등록 템플릿) — 사용자별.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class QuickEntry implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String label;
|
||||
private String type; // INCOME / EXPENSE
|
||||
private String category;
|
||||
private Long amount;
|
||||
private String memo;
|
||||
private Long walletId;
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
private String walletName; // 조회 시 조인(표시용)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 정기/반복 거래 규칙. recurring 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Recurring implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String title;
|
||||
private String type; // INCOME / EXPENSE / TRANSFER
|
||||
private Long amount;
|
||||
private String category;
|
||||
private String memo;
|
||||
private Long walletId;
|
||||
private String walletName; // 조인 표시용
|
||||
private Long toWalletId;
|
||||
private String toWalletName; // 조인 표시용
|
||||
private String frequency; // DAILY / WEEKLY / MONTHLY / YEARLY
|
||||
private Integer dayOfMonth;
|
||||
private Integer dayOfWeek; // 1=월 ~ 7=일
|
||||
private Integer monthOfYear;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private LocalDate lastRunDate;
|
||||
private Boolean active;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 계좌/카드/대출/투자 (사용자별). wallet 테이블과 매핑.
|
||||
* type=BANK(은행계좌) / CARD(카드) / LOAN(대출) / INVEST(투자).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Wallet implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String type; // BANK / CARD
|
||||
private String name; // 별칭
|
||||
private String issuer; // 은행명 / 카드사
|
||||
private String accountNumber; // 계좌번호 (BANK)
|
||||
private String cardType; // CREDIT / CHECK (CARD)
|
||||
private Long openingBalance; // 초기 잔액(부호있음: 자산+, 부채-)
|
||||
private LocalDate openingDate;
|
||||
private Long currentValue; // 현재 평가금액 (INVEST 전용, 수동 갱신)
|
||||
private Long loanAmount; // 대출 실행 금액(원금, 처음 빌린 금액) (LOAN 전용)
|
||||
private BigDecimal loanRate; // 연이자율(%) e.g. 5.25 (LOAN 전용)
|
||||
private String loanMethod; // 상환방식: EQUAL_PAYMENT / EQUAL_PRINCIPAL / BULLET (LOAN 전용)
|
||||
private Integer loanMonths; // 대출기간(개월) (LOAN 전용)
|
||||
private LocalDate loanStart; // 대출시작일 (LOAN 전용)
|
||||
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 은행 조회액과 정확히 맞추기 위한 선택 입력 (LOAN 전용)
|
||||
private Integer sortOrder; // 표시 순서 (타입 내 정렬)
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 가계부 항목 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class AccountEntryRequest {
|
||||
|
||||
@NotNull(message = "거래일을 입력하세요.")
|
||||
private LocalDate entryDate;
|
||||
|
||||
@NotBlank(message = "구분을 선택하세요.")
|
||||
@Pattern(regexp = "INCOME|EXPENSE|TRANSFER", message = "구분이 올바르지 않습니다.")
|
||||
private String type;
|
||||
|
||||
@Size(max = 50, message = "분류는 50자 이내여야 합니다.")
|
||||
private String category;
|
||||
|
||||
@NotNull(message = "금액을 입력하세요.")
|
||||
@PositiveOrZero(message = "금액은 0 이상이어야 합니다.")
|
||||
private Long amount;
|
||||
|
||||
@Size(max = 255, message = "메모는 255자 이내여야 합니다.")
|
||||
private String memo;
|
||||
|
||||
/** 발생/출금 계좌 ID (본인 wallet, 선택. 이체 시 출금 계좌) */
|
||||
private Long walletId;
|
||||
|
||||
/** 이체 입금 계좌 ID (TRANSFER 시 필수) */
|
||||
private Long toWalletId;
|
||||
|
||||
/** 카드 할부 개월수 (2~24, 일시불은 null). EXPENSE + 카드 결제에만 의미 있음 */
|
||||
@Min(value = 2, message = "할부는 2개월 이상이어야 합니다.")
|
||||
@Max(value = 24, message = "할부는 최대 24개월까지 가능합니다.")
|
||||
private Integer installmentMonths;
|
||||
|
||||
/** 선택한 태그 ID 목록 (태그 관리에 등록된 태그, 선택) */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 외화 결제(선택). currency 가 있으면 amount 는 환산된 원화여야 한다. */
|
||||
@Size(max = 3, message = "통화코드가 올바르지 않습니다.")
|
||||
private String currency; // USD/JPY 등. null/KRW = 원화
|
||||
private BigDecimal originalAmount; // 외화 원본 금액
|
||||
private BigDecimal exchangeRate; // 적용 환율(외화 1단위→원화)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.AccountEntry;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 가계부 항목 응답 (소유자 ID 제외, 태그 이름 포함).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class AccountEntryResponse {
|
||||
|
||||
private Long id;
|
||||
private LocalDate entryDate;
|
||||
private String type;
|
||||
private String category;
|
||||
private Long amount;
|
||||
private String memo;
|
||||
private Long walletId;
|
||||
private String walletName;
|
||||
private Long toWalletId;
|
||||
private String toWalletName;
|
||||
private Integer installmentMonths;
|
||||
private Boolean pending;
|
||||
private String currency;
|
||||
private BigDecimal originalAmount;
|
||||
private BigDecimal exchangeRate;
|
||||
private List<String> tags;
|
||||
|
||||
public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
|
||||
return AccountEntryResponse.builder()
|
||||
.id(e.getId())
|
||||
.entryDate(e.getEntryDate())
|
||||
.type(e.getType())
|
||||
.category(e.getCategory())
|
||||
.amount(e.getAmount())
|
||||
.memo(e.getMemo())
|
||||
.walletId(e.getWalletId())
|
||||
.walletName(e.getWalletName())
|
||||
.toWalletId(e.getToWalletId())
|
||||
.toWalletName(e.getToWalletName())
|
||||
.installmentMonths(e.getInstallmentMonths())
|
||||
.pending(Boolean.TRUE.equals(e.getPending()))
|
||||
.currency(e.getCurrency())
|
||||
.originalAmount(e.getOriginalAmount())
|
||||
.exchangeRate(e.getExchangeRate())
|
||||
.tags(tags)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 가계부 요약 (수입/지출/잔액).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class AccountSummary {
|
||||
|
||||
private long totalIncome;
|
||||
private long totalExpense;
|
||||
private long balance;
|
||||
|
||||
public static AccountSummary of(long income, long expense) {
|
||||
return AccountSummary.builder()
|
||||
.totalIncome(income)
|
||||
.totalExpense(expense)
|
||||
.balance(income - expense)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 가계부 태그 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class AccountTagRequest {
|
||||
|
||||
@NotBlank(message = "태그 이름을 입력하세요.")
|
||||
@Size(max = 50, message = "태그 이름은 50자 이내여야 합니다.")
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.AccountTag;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 가계부 태그 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class AccountTagResponse {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
|
||||
public static AccountTagResponse from(AccountTag t) {
|
||||
return AccountTagResponse.builder().id(t.getId()).name(t.getName()).build();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 기간 버킷별 예산 대비 지출.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class BudgetPeriodStat {
|
||||
|
||||
private String bucket;
|
||||
private long budget;
|
||||
private long expense;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 예산 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class BudgetRequest {
|
||||
|
||||
@NotBlank(message = "카테고리를 입력하세요.")
|
||||
@Size(max = 50, message = "카테고리는 50자 이내여야 합니다.")
|
||||
private String category;
|
||||
|
||||
private boolean fixed;
|
||||
|
||||
// 고정(fixed=true)
|
||||
@Pattern(regexp = "DAY|WEEK|MONTH|", message = "기준 단위가 올바르지 않습니다.")
|
||||
private String baseUnit;
|
||||
private Long baseAmount;
|
||||
|
||||
// 비고정(fixed=false)
|
||||
private Long daily;
|
||||
private Long weekly;
|
||||
private Long monthly;
|
||||
private Long yearly;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.Budget;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 예산 응답 (편집용).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class BudgetResponse {
|
||||
|
||||
private Long id;
|
||||
private String category;
|
||||
private boolean fixed;
|
||||
private String baseUnit;
|
||||
private Long baseAmount;
|
||||
private Long daily;
|
||||
private Long weekly;
|
||||
private Long monthly;
|
||||
private Long yearly;
|
||||
|
||||
public static BudgetResponse from(Budget b) {
|
||||
return BudgetResponse.builder()
|
||||
.id(b.getId())
|
||||
.category(b.getCategory())
|
||||
.fixed(Boolean.TRUE.equals(b.getFixed()))
|
||||
.baseUnit(b.getBaseUnit())
|
||||
.baseAmount(b.getBaseAmount())
|
||||
.daily(b.getDaily())
|
||||
.weekly(b.getWeekly())
|
||||
.monthly(b.getMonthly())
|
||||
.yearly(b.getYearly())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 특정 월의 예산 대비 지출 현황.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class BudgetStatus {
|
||||
|
||||
private Long id;
|
||||
private String category;
|
||||
private boolean fixed;
|
||||
private long monthlyBudget; // 이 달의 예산(고정은 실제 일수 반영)
|
||||
private long spent; // 이 달 지출
|
||||
private long remaining; // 잔여
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 분류 순서 변경 요청. ids 순서대로 sort_order 를 재설정한다.
|
||||
*/
|
||||
@Data
|
||||
public class CategoryReorderRequest {
|
||||
|
||||
private String type; // INCOME / EXPENSE
|
||||
private List<Long> ids; // 새 순서의 분류 id 목록
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 분류 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class CategoryRequest {
|
||||
|
||||
@NotBlank(message = "유형을 선택하세요.")
|
||||
@Pattern(regexp = "INCOME|EXPENSE", message = "유형이 올바르지 않습니다.")
|
||||
private String type;
|
||||
|
||||
@NotBlank(message = "분류 이름을 입력하세요.")
|
||||
@Size(max = 50, message = "분류 이름은 50자 이내여야 합니다.")
|
||||
private String name;
|
||||
|
||||
/** 대분류 id. null=대분류로 생성, 값=해당 대분류의 소분류로 생성/이동 */
|
||||
private Long parentId;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.AccountCategory;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 분류 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class CategoryResponse {
|
||||
|
||||
private Long id;
|
||||
private String type;
|
||||
private String name;
|
||||
private Long parentId; // 대분류 id (null=대분류)
|
||||
|
||||
public static CategoryResponse from(AccountCategory c) {
|
||||
return CategoryResponse.builder()
|
||||
.id(c.getId()).type(c.getType()).name(c.getName()).parentId(c.getParentId())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 분류(카테고리)별 합계 통계. (파이차트용)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class CategoryStat {
|
||||
|
||||
private String category; // 분류명 (없으면 "미분류")
|
||||
private long total; // 합계 금액
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 미확인(알림 자동인식) 내역 확정 요청 — 분류·계좌 보정(선택).
|
||||
*/
|
||||
@Data
|
||||
public class ConfirmRequest {
|
||||
|
||||
private String category;
|
||||
private Long walletId;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 월 예상 수입 요청/응답.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ExpectedIncomeRequest {
|
||||
|
||||
@PositiveOrZero(message = "예상 수입은 0 이상이어야 합니다.")
|
||||
private Long expectedIncome;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 보유 종목 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class HoldingRequest {
|
||||
|
||||
@NotNull(message = "투자계좌를 선택하세요.")
|
||||
private Long walletId;
|
||||
|
||||
@NotBlank(message = "종목명을 입력하세요.")
|
||||
@Size(max = 100, message = "종목명은 100자 이내여야 합니다.")
|
||||
private String name;
|
||||
|
||||
@Size(max = 20, message = "종목코드는 20자 이내여야 합니다.")
|
||||
private String ticker;
|
||||
|
||||
/** 현재가(원, 수동 갱신). 미입력 시 평단 기준 평가 */
|
||||
@PositiveOrZero(message = "현재가는 0 이상이어야 합니다.")
|
||||
private Long currentPrice;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 보유 종목 응답 (매매이력으로 산출한 집계 포함).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class HoldingResponse {
|
||||
|
||||
private Long id;
|
||||
private Long walletId;
|
||||
private String name;
|
||||
private String ticker;
|
||||
private Long currentPrice; // 현재가(수동, null 가능)
|
||||
|
||||
private BigDecimal quantity; // 보유수량 (매수−매도, 소수점 가능)
|
||||
private Long avgPrice; // 평균매입단가 (보유분, 반올림 표시값)
|
||||
private Long costBasis; // 보유분 매입원가 합(수수료 포함)
|
||||
private Long evalValue; // 평가금액 = 수량 × (현재가 ?? 평단)
|
||||
private Long evalGain; // 평가손익(미실현) = 평가금액 − 매입원가
|
||||
private Double returnPct; // 수익률(%) = 평가손익 / 매입원가
|
||||
private Long realizedPL; // 실현손익(매도 누적)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 월별 순자산 추이 한 점. (순자산 = 모든 계좌 잔액 합, 부채는 음수)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class NetWorthPoint {
|
||||
|
||||
private String month; // "yyyy-MM"
|
||||
private long netWorth; // 해당 월말 기준 순자산
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 순자산 요약. totalAssets(총자산) - totalLiabilities(총부채) = netWorth.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class NetWorthResponse {
|
||||
|
||||
private long totalAssets;
|
||||
private long totalLiabilities;
|
||||
private long netWorth;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 카드 결제 푸시 알림 원문 (네이티브 알림 리스너 → 백엔드).
|
||||
* 빠른입력(자연어) 파싱 시에는 사용자의 분류·계좌 후보 목록을 함께 실어 AI가 그 중에서 고르게 한다.
|
||||
*/
|
||||
@Data
|
||||
public class NotificationRequest {
|
||||
|
||||
/** 알림을 띄운 앱 패키지명 (예: 카드사 앱) */
|
||||
private String app;
|
||||
|
||||
/** 알림 제목 */
|
||||
private String title;
|
||||
|
||||
/** 알림 본문 */
|
||||
private String text;
|
||||
|
||||
/** (선택) AI 파싱용 분류 후보 이름 목록 — 이 중에서 카테고리를 고르게 함(없으면 미추천) */
|
||||
private List<String> categoryHints;
|
||||
|
||||
/** (선택) AI 파싱용 결제수단(계좌) 후보 이름 목록 — 텍스트에 결제수단 언급 시 이 중에서 고름 */
|
||||
private List<String> walletHints;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 문자/푸시 텍스트 파싱 결과 (내역 추가 폼 자동 채움용). 저장하지 않음.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class ParsedEntryResponse {
|
||||
|
||||
private boolean recognized; // 거래로 인식되었는가
|
||||
private String type; // INCOME / EXPENSE / TRANSFER
|
||||
private long amount;
|
||||
private String merchant; // 가맹점/적요(메모 후보)
|
||||
private LocalDate date;
|
||||
private String category; // AI 추천 분류(후보 목록 중). 없으면 빈 문자열
|
||||
private String wallet; // AI 추천 계좌(후보 중). 지출/수입=결제수단, 이체=보내는(출금) 계좌
|
||||
private String toWallet; // 이체 시 받는(입금) 계좌(후보 중). 아니면 빈 문자열
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 기간 버킷별 수입/지출 통계.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class PeriodStat {
|
||||
|
||||
private String bucket; // 일/주/월/년 라벨 (예: "5", "1", "2026")
|
||||
private long income;
|
||||
private long expense;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 자주 쓰는 내역 생성 요청.
|
||||
*/
|
||||
@Data
|
||||
public class QuickEntryRequest {
|
||||
|
||||
@Size(max = 50, message = "이름은 50자 이내여야 합니다.")
|
||||
private String label;
|
||||
|
||||
@Pattern(regexp = "INCOME|EXPENSE", message = "구분이 올바르지 않습니다.")
|
||||
private String type;
|
||||
|
||||
@Size(max = 50)
|
||||
private String category;
|
||||
|
||||
@NotNull(message = "금액을 입력하세요.")
|
||||
@Positive(message = "금액은 0보다 커야 합니다.")
|
||||
private Long amount;
|
||||
|
||||
@Size(max = 255)
|
||||
private String memo;
|
||||
|
||||
private Long walletId;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.QuickEntry;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 자주 쓰는 내역 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class QuickEntryResponse {
|
||||
|
||||
private Long id;
|
||||
private String label;
|
||||
private String type;
|
||||
private String category;
|
||||
private Long amount;
|
||||
private String memo;
|
||||
private Long walletId;
|
||||
private String walletName;
|
||||
|
||||
public static QuickEntryResponse from(QuickEntry q) {
|
||||
return QuickEntryResponse.builder()
|
||||
.id(q.getId()).label(q.getLabel()).type(q.getType()).category(q.getCategory())
|
||||
.amount(q.getAmount()).memo(q.getMemo()).walletId(q.getWalletId())
|
||||
.walletName(q.getWalletName())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 정기 거래 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class RecurringRequest {
|
||||
|
||||
@NotBlank(message = "이름을 입력하세요.")
|
||||
@Size(max = 100, message = "이름은 100자 이내여야 합니다.")
|
||||
private String title;
|
||||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "INCOME|EXPENSE|TRANSFER", message = "구분이 올바르지 않습니다.")
|
||||
private String type;
|
||||
|
||||
@NotNull(message = "금액을 입력하세요.")
|
||||
@Positive(message = "금액은 0보다 커야 합니다.")
|
||||
private Long amount;
|
||||
|
||||
@Size(max = 50)
|
||||
private String category;
|
||||
|
||||
@Size(max = 255)
|
||||
private String memo;
|
||||
|
||||
private Long walletId;
|
||||
private Long toWalletId;
|
||||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "DAILY|WEEKLY|MONTHLY|YEARLY", message = "주기가 올바르지 않습니다.")
|
||||
private String frequency;
|
||||
|
||||
private Integer dayOfMonth; // MONTHLY/YEARLY
|
||||
private Integer dayOfWeek; // WEEKLY (1~7)
|
||||
private Integer monthOfYear; // YEARLY (1~12)
|
||||
|
||||
@NotNull(message = "시작일을 입력하세요.")
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
|
||||
private boolean active = true;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.Recurring;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 정기 거래 응답 (다음 예정일 포함).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class RecurringResponse {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String type;
|
||||
private Long amount;
|
||||
private String category;
|
||||
private String memo;
|
||||
private Long walletId;
|
||||
private String walletName;
|
||||
private Long toWalletId;
|
||||
private String toWalletName;
|
||||
private String frequency;
|
||||
private Integer dayOfMonth;
|
||||
private Integer dayOfWeek;
|
||||
private Integer monthOfYear;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private LocalDate lastRunDate;
|
||||
private LocalDate nextDate; // 다음 예정일(계산)
|
||||
private boolean active;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 대출/카드 상환·납부 요청. 원금은 이체, 이자·연회비는 지출로 분리 생성된다.
|
||||
*/
|
||||
@Data
|
||||
public class RepaymentRequest {
|
||||
|
||||
@NotNull(message = "거래일을 입력하세요.")
|
||||
private LocalDate entryDate;
|
||||
|
||||
@NotNull(message = "출금 계좌를 선택하세요.")
|
||||
private Long fromWalletId;
|
||||
|
||||
@NotNull(message = "대상(대출/카드)을 선택하세요.")
|
||||
private Long targetWalletId;
|
||||
|
||||
@PositiveOrZero(message = "원금은 0 이상이어야 합니다.")
|
||||
private Long principal;
|
||||
|
||||
@PositiveOrZero(message = "이자는 0 이상이어야 합니다.")
|
||||
private Long interest;
|
||||
|
||||
@PositiveOrZero(message = "연회비는 0 이상이어야 합니다.")
|
||||
private Long annualFee;
|
||||
|
||||
@Size(max = 255, message = "메모는 255자 이내여야 합니다.")
|
||||
private String memo;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 데이터 복구(가져오기) 요청 — 엑셀 백업을 파싱한 결과.
|
||||
* 계좌는 원본 ID(oldId) 기준으로 매핑(같은 이름 계좌가 있어도 정확). 분류·태그는 이름 기준(유니크).
|
||||
*/
|
||||
@Data
|
||||
public class RestoreRequest {
|
||||
|
||||
private List<Wal> wallets;
|
||||
private List<Cat> categories;
|
||||
private List<String> tags;
|
||||
private List<Entry> entries;
|
||||
private List<Recur> recurrings;
|
||||
private List<BudgetRequest> budgets;
|
||||
private List<Quick> quickEntries;
|
||||
|
||||
@Data
|
||||
public static class Wal {
|
||||
private Long oldId; // 원본 계좌 id (매핑 키)
|
||||
private String type;
|
||||
private String name;
|
||||
private String issuer;
|
||||
private String accountNumber;
|
||||
private String cardType;
|
||||
private Long openingBalance;
|
||||
private LocalDate openingDate;
|
||||
private Long currentValue;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Cat {
|
||||
private String type;
|
||||
private String name;
|
||||
private String parent; // 대분류명 (없으면 대분류)
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Entry {
|
||||
private LocalDate entryDate;
|
||||
private String type;
|
||||
private String category;
|
||||
private Long amount;
|
||||
private String memo;
|
||||
private Long walletId; // 원본 계좌 id
|
||||
private Long toWalletId; // 원본 입금 계좌 id(이체)
|
||||
private Integer installmentMonths;
|
||||
private List<String> tags; // 태그명
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Recur {
|
||||
private String title;
|
||||
private String type;
|
||||
private Long amount;
|
||||
private String category;
|
||||
private String memo;
|
||||
private Long walletId;
|
||||
private Long toWalletId;
|
||||
private String frequency;
|
||||
private Integer dayOfMonth;
|
||||
private Integer dayOfWeek;
|
||||
private Integer monthOfYear;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private Boolean active;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Quick {
|
||||
private String label;
|
||||
private String type;
|
||||
private String category;
|
||||
private Long amount;
|
||||
private String memo;
|
||||
private Long walletId;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 태그 순서 변경 요청. ids 순서대로 sort_order 를 재설정한다.
|
||||
*/
|
||||
@Data
|
||||
public class TagReorderRequest {
|
||||
|
||||
private List<Long> ids; // 새 순서의 태그 id 목록
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 매매(매수/매도) 등록 요청.
|
||||
*/
|
||||
@Data
|
||||
public class TradeRequest {
|
||||
|
||||
@Pattern(regexp = "BUY|SELL", message = "매매 구분이 올바르지 않습니다.")
|
||||
private String tradeType;
|
||||
|
||||
@NotNull(message = "거래일을 입력하세요.")
|
||||
private LocalDate tradeDate;
|
||||
|
||||
@Positive(message = "수량은 0보다 커야 합니다.")
|
||||
private BigDecimal quantity;
|
||||
|
||||
@PositiveOrZero(message = "단가는 0 이상이어야 합니다.")
|
||||
private Long price;
|
||||
|
||||
/** 수수료/세금(원). 미입력 시 0 */
|
||||
@PositiveOrZero(message = "수수료는 0 이상이어야 합니다.")
|
||||
private Long fee;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.InvestTrade;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 매매 이력 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class TradeResponse {
|
||||
|
||||
private Long id;
|
||||
private Long holdingId;
|
||||
private String tradeType;
|
||||
private LocalDate tradeDate;
|
||||
private BigDecimal quantity;
|
||||
private Long price;
|
||||
private Long fee;
|
||||
private Long amount; // 거래금액 = 수량 × 단가 (수수료 제외, 원 단위 반올림)
|
||||
|
||||
public static TradeResponse from(InvestTrade t) {
|
||||
long amount = t.getQuantity()
|
||||
.multiply(BigDecimal.valueOf(t.getPrice()))
|
||||
.setScale(0, RoundingMode.HALF_UP)
|
||||
.longValue();
|
||||
return TradeResponse.builder()
|
||||
.id(t.getId())
|
||||
.holdingId(t.getHoldingId())
|
||||
.tradeType(t.getTradeType())
|
||||
.tradeDate(t.getTradeDate())
|
||||
.quantity(t.getQuantity())
|
||||
.price(t.getPrice())
|
||||
.fee(t.getFee())
|
||||
.amount(amount)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 계좌/카드 순서 변경 요청. ids 순서대로 sort_order 를 재설정한다.
|
||||
*/
|
||||
@Data
|
||||
public class WalletReorderRequest {
|
||||
|
||||
private String type; // BANK / CARD / LOAN / INVEST
|
||||
private List<Long> ids; // 새 순서의 wallet id 목록
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 계좌/카드/대출 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class WalletRequest {
|
||||
|
||||
@NotBlank(message = "유형을 선택하세요.")
|
||||
@Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST|MINUS", message = "유형이 올바르지 않습니다.")
|
||||
private String type;
|
||||
|
||||
@NotBlank(message = "이름을 입력하세요.")
|
||||
@Size(max = 50, message = "이름은 50자 이내여야 합니다.")
|
||||
private String name;
|
||||
|
||||
@Size(max = 50, message = "50자 이내여야 합니다.")
|
||||
private String issuer; // 은행명 / 카드사
|
||||
|
||||
@Size(max = 50, message = "50자 이내여야 합니다.")
|
||||
private String accountNumber; // 계좌번호 (BANK)
|
||||
|
||||
@Pattern(regexp = "CREDIT|CHECK|", message = "카드 종류가 올바르지 않습니다.")
|
||||
private String cardType; // CREDIT / CHECK (CARD)
|
||||
|
||||
/** 초기 잔액 (부호있음: 자산+, 부채-). 미입력 시 0 */
|
||||
private Long openingBalance;
|
||||
|
||||
private LocalDate openingDate;
|
||||
|
||||
/** 현재 평가금액 (INVEST 전용, 수동 갱신) */
|
||||
private Long currentValue;
|
||||
|
||||
// ===== 대출(LOAN) 전용 =====
|
||||
private Long loanAmount; // 대출 실행 금액(원금)
|
||||
|
||||
@DecimalMin(value = "0.0", inclusive = true)
|
||||
@DecimalMax(value = "100.0", message = "이자율은 100% 이하여야 합니다.")
|
||||
private BigDecimal loanRate; // 연이자율(%)
|
||||
|
||||
@Pattern(regexp = "EQUAL_PAYMENT|EQUAL_PRINCIPAL|BULLET|", message = "상환방식이 올바르지 않습니다.")
|
||||
private String loanMethod; // 상환방식
|
||||
|
||||
private Integer loanMonths; // 대출기간(개월)
|
||||
private LocalDate loanStart; // 대출시작일
|
||||
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 선택
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.Wallet;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 계좌/카드/대출 응답. balance 는 현재 잔액(서비스에서 계산해 채움).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class WalletResponse {
|
||||
|
||||
private Long id;
|
||||
private String type;
|
||||
private String name;
|
||||
private String issuer;
|
||||
private String accountNumber;
|
||||
private String cardType;
|
||||
private Long openingBalance;
|
||||
private LocalDate openingDate;
|
||||
private Long balance; // 현재 잔액(부호있음). INVEST는 총평가(예수금+주식)
|
||||
|
||||
// 투자(INVEST) 전용 — 서비스에서 포트폴리오로 산출
|
||||
private Long investedAmount; // 순입금(투입원금) = 입금−출금
|
||||
private Long deposit; // 예수금(미투자 현금) = 투입원금 + 매도 − 매수
|
||||
private Long stockValue; // 주식 평가금액
|
||||
private Long valuationGain; // 총손익(실현+평가) = 총평가 − 투입원금
|
||||
private Long currentValue; // 평가액 직접입력값(퇴직연금·연금 등 수동 갱신). 있으면 총평가로 사용
|
||||
private Boolean manualValuation; // true면 평가액 직접입력형(종목 자동계산 대신)
|
||||
|
||||
// 대출(LOAN) 전용
|
||||
private Long loanAmount; // 대출 실행 금액(원금)
|
||||
private BigDecimal loanRate; // 연이자율(%) e.g. 5.25
|
||||
private String loanMethod; // EQUAL_PAYMENT / EQUAL_PRINCIPAL / BULLET
|
||||
private Integer loanMonths; // 대출기간(개월)
|
||||
private LocalDate loanStart; // 대출시작일
|
||||
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 선택 입력
|
||||
|
||||
public static WalletResponse from(Wallet w, long balance) {
|
||||
return WalletResponse.builder()
|
||||
.id(w.getId())
|
||||
.type(w.getType())
|
||||
.name(w.getName())
|
||||
.issuer(w.getIssuer())
|
||||
.accountNumber(w.getAccountNumber())
|
||||
.cardType(w.getCardType())
|
||||
.openingBalance(w.getOpeningBalance())
|
||||
.openingDate(w.getOpeningDate())
|
||||
.balance(balance)
|
||||
.currentValue(w.getCurrentValue())
|
||||
.loanAmount(w.getLoanAmount())
|
||||
.loanRate(w.getLoanRate())
|
||||
.loanMethod(w.getLoanMethod())
|
||||
.loanMonths(w.getLoanMonths())
|
||||
.loanStart(w.getLoanStart())
|
||||
.loanPayment(w.getLoanPayment())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.AccountEntry;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 가계부 매퍼. 모든 조회/수정은 member_id(소유자)로 격리한다.
|
||||
*/
|
||||
@Mapper
|
||||
public interface AccountEntryMapper {
|
||||
|
||||
List<AccountEntry> findByMember(@Param("memberId") Long memberId,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month,
|
||||
@Param("type") String type,
|
||||
@Param("category") String category,
|
||||
@Param("walletId") Long walletId,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("tagId") Long tagId);
|
||||
|
||||
/** 특정 계좌(wallet)와 연관된 내역 (wallet_id 또는 to_wallet_id 가 해당 계좌) */
|
||||
List<AccountEntry> findByWalletAndMember(@Param("memberId") Long memberId,
|
||||
@Param("walletId") Long walletId);
|
||||
|
||||
AccountEntry findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/** 구분(type)별 합계 — {type, total} 행 반환 */
|
||||
List<Map<String, Object>> sumByType(@Param("memberId") Long memberId,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
/** 카테고리별 지출 합계 — {category, total} 행 반환 (예산 대비용) */
|
||||
List<Map<String, Object>> sumExpenseByCategory(@Param("memberId") Long memberId,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
/** 구분(type)별 분류 합계 — {category, total} 행 반환 (파이차트용) */
|
||||
List<Map<String, Object>> sumByCategory(@Param("memberId") Long memberId,
|
||||
@Param("type") String type,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
/** 월별 순현금흐름(수입−지출, 계좌 지정분) — {ym, net} 행 반환 (순자산 추이용) */
|
||||
List<Map<String, Object>> monthlyWalletFlow(@Param("memberId") Long memberId);
|
||||
|
||||
/** 주별 순현금흐름(수입−지출, 계좌 지정분) — {wk(=주 월요일 yyyy-MM-dd), net} (주간 순자산 추이용) */
|
||||
List<Map<String, Object>> weeklyWalletFlow(@Param("memberId") Long memberId);
|
||||
|
||||
/** 기간 버킷(일/주/월/년)별 수입·지출 — {bucket, income, expense} (이체 제외) */
|
||||
List<Map<String, Object>> statsByPeriod(@Param("memberId") Long memberId,
|
||||
@Param("unit") String unit,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
int insert(AccountEntry entry);
|
||||
|
||||
int update(AccountEntry entry);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/* ----- 알림 자동인식(미확인) ----- */
|
||||
/** 중복방지 키로 기존 알림 내역 조회 */
|
||||
AccountEntry findByMemberAndNotifKey(@Param("memberId") Long memberId, @Param("notifKey") String notifKey);
|
||||
|
||||
/** 미확인(확인 필요) 내역 개수 */
|
||||
int countPending(@Param("memberId") Long memberId);
|
||||
|
||||
/** 자동인식 분류 학습: 같은 메모 키워드로 과거에 가장 많이 쓴 분류 1건(없으면 null) */
|
||||
String findLearnedCategory(@Param("memberId") Long memberId,
|
||||
@Param("type") String type,
|
||||
@Param("memo") String memo);
|
||||
|
||||
/** 미확인 내역 확정 (pending=0, 분류/계좌 보정) */
|
||||
int confirm(@Param("id") Long id, @Param("memberId") Long memberId,
|
||||
@Param("category") String category, @Param("walletId") Long walletId);
|
||||
|
||||
/** 기존 내역의 (type별) 사용된 분류명 distinct (분류 불러오기용) */
|
||||
List<String> findDistinctCategories(@Param("memberId") Long memberId, @Param("type") String type);
|
||||
|
||||
/** 분류 이름 변경 시 기존 내역 일괄 갱신 */
|
||||
int renameCategory(@Param("memberId") Long memberId,
|
||||
@Param("type") String type,
|
||||
@Param("oldName") String oldName,
|
||||
@Param("newName") String newName);
|
||||
|
||||
/* ----- 항목-태그 ----- */
|
||||
int insertEntryTag(@Param("entryId") Long entryId, @Param("tagId") Long tagId);
|
||||
|
||||
int deleteEntryTagsByEntryId(@Param("entryId") Long entryId);
|
||||
|
||||
List<String> findTagNamesByEntryId(@Param("entryId") Long entryId);
|
||||
|
||||
/** 복수 항목의 태그를 한 번에 조회 — {entryId, tagName} 맵 반환 */
|
||||
List<Map<String, Object>> findTagsByEntryIds(@Param("entryIds") List<Long> entryIds);
|
||||
|
||||
/** 특정 연·월에 기록이 있는 서로 다른 날짜 수 (한 달 개근 판정용) */
|
||||
int countDistinctEntryDays(@Param("memberId") Long memberId,
|
||||
@Param("year") int year,
|
||||
@Param("month") int month);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 월 예상 수입 매퍼 (budget_income, 월별).
|
||||
*/
|
||||
@Mapper
|
||||
public interface AccountSettingMapper {
|
||||
|
||||
/** 해당 연·월의 예상 수입 (없으면 null) */
|
||||
Long findExpectedIncome(@Param("memberId") Long memberId,
|
||||
@Param("year") int year,
|
||||
@Param("month") int month);
|
||||
|
||||
/** 해당 연·월 예상 수입 upsert */
|
||||
int upsertExpectedIncome(@Param("memberId") Long memberId,
|
||||
@Param("year") int year,
|
||||
@Param("month") int month,
|
||||
@Param("expectedIncome") Long expectedIncome);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.AccountTag;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 가계부 태그 매퍼. 모든 동작은 member_id(소유자)로 격리.
|
||||
*/
|
||||
@Mapper
|
||||
public interface AccountTagMapper {
|
||||
|
||||
List<AccountTag> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
AccountTag findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int countByName(@Param("memberId") Long memberId,
|
||||
@Param("name") String name,
|
||||
@Param("excludeId") Long excludeId);
|
||||
|
||||
int insert(AccountTag tag);
|
||||
|
||||
int update(AccountTag tag);
|
||||
|
||||
int updateSortOrder(@Param("id") Long id, @Param("memberId") Long memberId, @Param("sortOrder") int sortOrder);
|
||||
|
||||
Integer maxSortOrder(@Param("memberId") Long memberId);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/** 주어진 ID 중 해당 사용자 소유로 실제 존재하는 태그 ID만 반환 */
|
||||
List<Long> findExistingIds(@Param("memberId") Long memberId, @Param("ids") List<Long> ids);
|
||||
|
||||
/** 태그 삭제 시 항목 연결도 제거 */
|
||||
int deleteEntryLinksByTagId(@Param("tagId") Long tagId);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 데이터 복구 시 회원의 기존 가계부 데이터를 전부 삭제(트랜잭션 내에서 호출).
|
||||
*/
|
||||
@Mapper
|
||||
public interface BackupMapper {
|
||||
|
||||
int deleteEntryTags(@Param("memberId") Long memberId);
|
||||
int deleteEntries(@Param("memberId") Long memberId);
|
||||
int deleteRecurrings(@Param("memberId") Long memberId);
|
||||
int deleteBudgets(@Param("memberId") Long memberId);
|
||||
int deleteBudgetIncome(@Param("memberId") Long memberId);
|
||||
int deleteBudgetOnetime(@Param("memberId") Long memberId);
|
||||
int deleteQuickEntries(@Param("memberId") Long memberId);
|
||||
int deleteInvestTrades(@Param("memberId") Long memberId);
|
||||
int deleteInvestHoldings(@Param("memberId") Long memberId);
|
||||
int deleteCategories(@Param("memberId") Long memberId);
|
||||
int deleteTags(@Param("memberId") Long memberId);
|
||||
int deleteWallets(@Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.Budget;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 예산 매퍼. 모든 동작은 member_id(소유자)로 격리.
|
||||
*/
|
||||
@Mapper
|
||||
public interface BudgetMapper {
|
||||
|
||||
List<Budget> findByMember(@Param("memberId") Long memberId,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
Budget findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int countByCategory(@Param("memberId") Long memberId,
|
||||
@Param("category") String category,
|
||||
@Param("excludeId") Long excludeId,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
/** fromYear/fromMonth 예산을 toYear/toMonth 로 복사(같은 분류는 덮어씀). 복사된 건수 반환 */
|
||||
int copyMonth(@Param("memberId") Long memberId,
|
||||
@Param("fromYear") Integer fromYear,
|
||||
@Param("fromMonth") Integer fromMonth,
|
||||
@Param("toYear") Integer toYear,
|
||||
@Param("toMonth") Integer toMonth);
|
||||
|
||||
int insert(Budget budget);
|
||||
|
||||
int update(Budget budget);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/** 분류 이름 변경 시 예산 일괄 갱신 */
|
||||
int renameCategory(@Param("memberId") Long memberId,
|
||||
@Param("oldName") String oldName,
|
||||
@Param("newName") String newName);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.AccountCategory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 분류 매퍼. 모든 동작은 member_id(소유자)로 격리.
|
||||
*/
|
||||
@Mapper
|
||||
public interface CategoryMapper {
|
||||
|
||||
List<AccountCategory> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
AccountCategory findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/** 이름으로 조회 (기본분류 불러오기 중복 확인용) */
|
||||
AccountCategory findByMemberTypeName(@Param("memberId") Long memberId,
|
||||
@Param("type") String type,
|
||||
@Param("name") String name);
|
||||
|
||||
/** 해당 대분류(parent_id)에 속한 소분류 개수 */
|
||||
int countChildren(@Param("memberId") Long memberId, @Param("parentId") Long parentId);
|
||||
|
||||
int countByName(@Param("memberId") Long memberId,
|
||||
@Param("type") String type,
|
||||
@Param("name") String name,
|
||||
@Param("excludeId") Long excludeId);
|
||||
|
||||
int insert(AccountCategory category);
|
||||
|
||||
int insertIgnore(AccountCategory category); // 기존 분류 불러오기용
|
||||
|
||||
int update(AccountCategory category);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/** 순서(sort_order) 변경 */
|
||||
int updateSortOrder(@Param("id") Long id, @Param("memberId") Long memberId, @Param("sortOrder") int sortOrder);
|
||||
|
||||
/** 해당 타입의 현재 최대 sort_order (신규 분류를 맨 뒤에 배치) */
|
||||
Integer maxSortOrder(@Param("memberId") Long memberId, @Param("type") String type);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.DefaultCategory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 기본(디폴트) 분류 매퍼 — 전체 공용(소유자 격리 없음).
|
||||
*/
|
||||
@Mapper
|
||||
public interface DefaultCategoryMapper {
|
||||
|
||||
List<DefaultCategory> findAll();
|
||||
|
||||
DefaultCategory findById(@Param("id") Long id);
|
||||
|
||||
int countChildren(@Param("parentId") Long parentId);
|
||||
|
||||
int countByName(@Param("type") String type, @Param("name") String name, @Param("excludeId") Long excludeId);
|
||||
|
||||
int insert(DefaultCategory category);
|
||||
|
||||
int update(DefaultCategory category);
|
||||
|
||||
int delete(@Param("id") Long id);
|
||||
|
||||
int updateSortOrder(@Param("id") Long id, @Param("sortOrder") int sortOrder);
|
||||
|
||||
Integer maxSortOrder(@Param("type") String type);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.InvestHolding;
|
||||
import com.sb.web.account.domain.InvestTrade;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 투자 보유종목/매매이력 매퍼. 모든 조회/수정은 member_id(소유자)로 격리한다.
|
||||
*/
|
||||
@Mapper
|
||||
public interface InvestMapper {
|
||||
|
||||
/* ===== 보유 종목 ===== */
|
||||
List<InvestHolding> findHoldingsByMember(@Param("memberId") Long memberId);
|
||||
|
||||
List<InvestHolding> findHoldingsByWallet(@Param("memberId") Long memberId,
|
||||
@Param("walletId") Long walletId);
|
||||
|
||||
InvestHolding findHoldingByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int insertHolding(InvestHolding holding);
|
||||
|
||||
int updateHolding(InvestHolding holding);
|
||||
|
||||
int deleteHolding(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/* ===== 매매 이력 ===== */
|
||||
/** 회원의 전체 매매(집계용) — 종목·거래일·id 순 */
|
||||
List<InvestTrade> findTradesByMember(@Param("memberId") Long memberId);
|
||||
|
||||
List<InvestTrade> findTradesByHolding(@Param("holdingId") Long holdingId,
|
||||
@Param("memberId") Long memberId);
|
||||
|
||||
InvestTrade findTradeByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int insertTrade(InvestTrade trade);
|
||||
|
||||
int updateTrade(InvestTrade trade);
|
||||
|
||||
int deleteTrade(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int deleteTradesByHolding(@Param("holdingId") Long holdingId, @Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.QuickEntry;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 자주 쓰는 내역 매퍼 — 소유자(member_id) 격리.
|
||||
*/
|
||||
@Mapper
|
||||
public interface QuickEntryMapper {
|
||||
|
||||
List<QuickEntry> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
QuickEntry findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int insert(QuickEntry e);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
Integer maxSortOrder(@Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.Recurring;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 정기 거래 매퍼. 모든 동작은 member_id(소유자)로 격리.
|
||||
*/
|
||||
@Mapper
|
||||
public interface RecurringMapper {
|
||||
|
||||
List<Recurring> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
/** 회원의 정기 거래 개수 (무료 회원 등록 한도 판정용) */
|
||||
int countByMember(@Param("memberId") Long memberId);
|
||||
|
||||
/** 활성 규칙 (자동 생성용) */
|
||||
List<Recurring> findActiveByMember(@Param("memberId") Long memberId);
|
||||
|
||||
Recurring findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int insert(Recurring r);
|
||||
|
||||
int update(Recurring r);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int updateLastRun(@Param("id") Long id, @Param("lastRunDate") LocalDate lastRunDate);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.Wallet;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 계좌/카드/대출 매퍼. 모든 동작은 member_id(소유자)로 격리.
|
||||
*/
|
||||
@Mapper
|
||||
public interface WalletMapper {
|
||||
|
||||
List<Wallet> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
/** 계좌별 현재 잔액 {id, balance} */
|
||||
List<Map<String, Object>> findBalances(@Param("memberId") Long memberId);
|
||||
|
||||
Wallet findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int insert(Wallet wallet);
|
||||
|
||||
int update(Wallet wallet);
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/** 순서(sort_order) 변경 */
|
||||
int updateSortOrder(@Param("id") Long id, @Param("memberId") Long memberId, @Param("sortOrder") int sortOrder);
|
||||
|
||||
/** 해당 타입의 현재 최대 sort_order (신규 계좌를 맨 뒤에 배치) */
|
||||
Integer maxSortOrder(@Param("memberId") Long memberId, @Param("type") String type);
|
||||
|
||||
/** 해당 타입의 계좌 개수 (무료 회원 종류별 등록 한도 판정용) */
|
||||
int countByType(@Param("memberId") Long memberId, @Param("type") String type);
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.AccountEntry;
|
||||
import com.sb.web.account.domain.AccountTag;
|
||||
import com.sb.web.account.domain.Wallet;
|
||||
import com.sb.web.account.dto.AccountEntryRequest;
|
||||
import com.sb.web.account.dto.AccountEntryResponse;
|
||||
import com.sb.web.account.dto.AccountSummary;
|
||||
import com.sb.web.account.dto.ConfirmRequest;
|
||||
import com.sb.web.account.dto.NotificationRequest;
|
||||
import com.sb.web.account.dto.ParsedEntryResponse;
|
||||
import com.sb.web.account.dto.AccountTagRequest;
|
||||
import com.sb.web.account.dto.AccountTagResponse;
|
||||
import com.sb.web.account.dto.CategoryStat;
|
||||
import com.sb.web.account.dto.NetWorthPoint;
|
||||
import com.sb.web.account.dto.NetWorthResponse;
|
||||
import com.sb.web.account.dto.PeriodStat;
|
||||
import com.sb.web.account.dto.RepaymentRequest;
|
||||
import com.sb.web.account.dto.WalletRequest;
|
||||
import com.sb.web.account.dto.WalletResponse;
|
||||
import com.sb.web.account.mapper.AccountEntryMapper;
|
||||
import com.sb.web.account.mapper.AccountTagMapper;
|
||||
import com.sb.web.account.mapper.WalletMapper;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.mapper.MemberMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import com.sb.web.point.PointService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 가계부 서비스. 항목·태그 모두 memberId(소유자)로 격리되어 본인 데이터만 접근한다.
|
||||
* 가계부 태그는 사용자별(account_tag)로 관리한다.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AccountService {
|
||||
|
||||
private final AccountEntryMapper mapper;
|
||||
private final AccountTagMapper tagMapper;
|
||||
private final WalletMapper walletMapper;
|
||||
private final InvestService investService;
|
||||
private final CardNotificationParser notificationParser;
|
||||
private final AiEntryParser aiEntryParser;
|
||||
private final PointService pointService;
|
||||
private final MemberMapper memberMapper;
|
||||
|
||||
/* ===================== 항목 ===================== */
|
||||
|
||||
public List<AccountEntryResponse> list(Long memberId, Integer year, Integer month,
|
||||
String type, String category, Long walletId, String keyword, Long tagId) {
|
||||
List<AccountEntry> entries = mapper.findByMember(memberId, year, month,
|
||||
blankToNull(type), blankToNull(category), walletId, blankToNull(keyword), tagId);
|
||||
if (entries.isEmpty()) return List.of();
|
||||
// 태그 N+1 방지: 한 번에 모든 entry 의 태그를 조회 후 Map 으로 그룹화
|
||||
List<Long> ids = entries.stream().map(AccountEntry::getId).toList();
|
||||
Map<Long, List<String>> tagsByEntry = new HashMap<>();
|
||||
for (Map<String, Object> row : mapper.findTagsByEntryIds(ids)) {
|
||||
Object rawId = row.get("entry_id");
|
||||
String name = (String) row.get("tag_name");
|
||||
if (rawId == null || name == null) continue;
|
||||
Long eid = ((Number) rawId).longValue();
|
||||
tagsByEntry.computeIfAbsent(eid, k -> new ArrayList<>()).add(name);
|
||||
}
|
||||
return entries.stream()
|
||||
.map(e -> AccountEntryResponse.from(e, tagsByEntry.getOrDefault(e.getId(), List.of())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 분류(카테고리)별 합계 — 파이차트용. type=EXPENSE(기본)/INCOME */
|
||||
public List<CategoryStat> categoryStats(Long memberId, String type, Integer year, Integer month) {
|
||||
String t = "INCOME".equalsIgnoreCase(type) ? "INCOME" : "EXPENSE";
|
||||
return mapper.sumByCategory(memberId, t, year, month).stream()
|
||||
.map(r -> CategoryStat.builder()
|
||||
.category((String) r.get("category"))
|
||||
.total(((Number) r.get("total")).longValue())
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 월별 순자산 추이 (최근 months개월, 기본 12). 순자산 = 개시잔액 + 누적(수입−지출, 계좌 지정분) */
|
||||
public List<NetWorthPoint> netWorthTrend(Long memberId, Integer months) {
|
||||
int n = (months == null || months <= 0 || months > 60) ? 12 : months;
|
||||
YearMonth current = YearMonth.from(java.time.LocalDate.now());
|
||||
YearMonth start = current.minusMonths(n - 1L);
|
||||
|
||||
Map<String, Long> flow = new HashMap<>();
|
||||
for (Map<String, Object> row : mapper.monthlyWalletFlow(memberId)) {
|
||||
Object net = row.get("net");
|
||||
flow.put((String) row.get("ym"), net == null ? 0L : ((Number) net).longValue());
|
||||
}
|
||||
|
||||
// 개시잔액: 개시월이 범위 시작 이전(또는 미상)이면 기준선, 범위 내면 해당 월 버킷, 미래면 무시
|
||||
List<Wallet> ws = walletMapper.findByMember(memberId);
|
||||
Map<String, Long> openingByMonth = new HashMap<>();
|
||||
long baseline = 0;
|
||||
for (Wallet w : ws) {
|
||||
long ob = w.getOpeningBalance() == null ? 0L : w.getOpeningBalance();
|
||||
if (ob == 0) {
|
||||
continue;
|
||||
}
|
||||
YearMonth om = w.getOpeningDate() == null ? null : YearMonth.from(w.getOpeningDate());
|
||||
if (om == null || om.isBefore(start)) {
|
||||
baseline += ob;
|
||||
} else if (!om.isAfter(current)) {
|
||||
openingByMonth.merge(om.toString(), ob, Long::sum);
|
||||
}
|
||||
}
|
||||
// 범위 시작 이전의 현금흐름은 기준선에 누적
|
||||
String startKey = start.toString();
|
||||
for (Map.Entry<String, Long> e : flow.entrySet()) {
|
||||
if (e.getKey().compareTo(startKey) < 0) {
|
||||
baseline += e.getValue();
|
||||
}
|
||||
}
|
||||
|
||||
List<NetWorthPoint> result = new ArrayList<>();
|
||||
long running = baseline;
|
||||
for (YearMonth m = start; !m.isAfter(current); m = m.plusMonths(1)) {
|
||||
String key = m.toString();
|
||||
running += flow.getOrDefault(key, 0L);
|
||||
running += openingByMonth.getOrDefault(key, 0L);
|
||||
result.add(NetWorthPoint.builder().month(key).netWorth(running).build());
|
||||
}
|
||||
|
||||
// 투자 손익(실현+평가)은 현재 시점에만 반영 (과거 평가 이력이 없으므로 마지막 점에 가산)
|
||||
// 매매에 따른 예수금 증감 + 주식 평가금액 = 현금기준 대비 순자산 증감분
|
||||
if (!result.isEmpty()) {
|
||||
long investAdjust = 0;
|
||||
for (InvestService.WalletInvest wi : investService.valuationByWallet(memberId).values()) {
|
||||
investAdjust += wi.cashDelta + wi.stockEval;
|
||||
}
|
||||
if (investAdjust != 0) {
|
||||
NetWorthPoint last = result.get(result.size() - 1);
|
||||
last.setNetWorth(last.getNetWorth() + investAdjust);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 주별 순자산 추이 (월요일 기준 버킷). 기본 13주(최근 약 3개월). */
|
||||
public List<NetWorthPoint> netWorthTrendWeekly(Long memberId, Integer weeks) {
|
||||
int n = (weeks == null || weeks <= 0 || weeks > 53) ? 13 : weeks;
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate currentMon = today.minusDays(today.getDayOfWeek().getValue() - 1L); // 이번 주 월요일
|
||||
LocalDate startMon = currentMon.minusWeeks(n - 1L);
|
||||
|
||||
Map<String, Long> flow = new HashMap<>();
|
||||
for (Map<String, Object> row : mapper.weeklyWalletFlow(memberId)) {
|
||||
Object net = row.get("net");
|
||||
flow.put((String) row.get("wk"), net == null ? 0L : ((Number) net).longValue());
|
||||
}
|
||||
|
||||
// 개시잔액: 개시주가 시작주 이전(또는 미상)이면 기준선, 범위 내면 해당 주 버킷
|
||||
List<Wallet> ws = walletMapper.findByMember(memberId);
|
||||
Map<String, Long> openingByWeek = new HashMap<>();
|
||||
long baseline = 0;
|
||||
for (Wallet w : ws) {
|
||||
long ob = w.getOpeningBalance() == null ? 0L : w.getOpeningBalance();
|
||||
if (ob == 0) {
|
||||
continue;
|
||||
}
|
||||
LocalDate od = w.getOpeningDate();
|
||||
LocalDate om = od == null ? null : od.minusDays(od.getDayOfWeek().getValue() - 1L);
|
||||
if (om == null || om.isBefore(startMon)) {
|
||||
baseline += ob;
|
||||
} else if (!om.isAfter(currentMon)) {
|
||||
openingByWeek.merge(om.toString(), ob, Long::sum);
|
||||
}
|
||||
}
|
||||
String startKey = startMon.toString();
|
||||
for (Map.Entry<String, Long> e : flow.entrySet()) {
|
||||
if (e.getKey().compareTo(startKey) < 0) {
|
||||
baseline += e.getValue();
|
||||
}
|
||||
}
|
||||
|
||||
List<NetWorthPoint> result = new ArrayList<>();
|
||||
long running = baseline;
|
||||
for (LocalDate m = startMon; !m.isAfter(currentMon); m = m.plusWeeks(1)) {
|
||||
String key = m.toString();
|
||||
running += flow.getOrDefault(key, 0L);
|
||||
running += openingByWeek.getOrDefault(key, 0L);
|
||||
result.add(NetWorthPoint.builder().month(key).netWorth(running).build());
|
||||
}
|
||||
|
||||
// 투자 손익(실현+평가)은 과거 주별 이력이 없으므로 마지막 점에만 가산 (월간과 동일)
|
||||
if (!result.isEmpty()) {
|
||||
long investAdjust = 0;
|
||||
for (InvestService.WalletInvest wi : investService.valuationByWallet(memberId).values()) {
|
||||
investAdjust += wi.cashDelta + wi.stockEval;
|
||||
}
|
||||
if (investAdjust != 0) {
|
||||
NetWorthPoint last = result.get(result.size() - 1);
|
||||
last.setNetWorth(last.getNetWorth() + investAdjust);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 기간 버킷(일/주/월/년)별 수입·지출 통계 */
|
||||
public List<PeriodStat> stats(Long memberId, String unit, Integer year, Integer month) {
|
||||
String u = switch (unit == null ? "" : unit.toUpperCase()) {
|
||||
case "DAY", "WEEK", "YEAR" -> unit.toUpperCase();
|
||||
default -> "MONTH";
|
||||
};
|
||||
return mapper.statsByPeriod(memberId, u, year, month).stream()
|
||||
.map(row -> PeriodStat.builder()
|
||||
.bucket(String.valueOf(((Number) row.get("bucket")).longValue()))
|
||||
.income(((Number) row.get("income")).longValue())
|
||||
.expense(((Number) row.get("expense")).longValue())
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
public AccountSummary summary(Long memberId, Integer year, Integer month) {
|
||||
long income = 0;
|
||||
long expense = 0;
|
||||
for (Map<String, Object> row : mapper.sumByType(memberId, year, month)) {
|
||||
String type = (String) row.get("type");
|
||||
long total = ((Number) row.get("total")).longValue();
|
||||
if ("INCOME".equals(type)) {
|
||||
income = total;
|
||||
} else if ("EXPENSE".equals(type)) {
|
||||
expense = total;
|
||||
}
|
||||
}
|
||||
return AccountSummary.of(income, expense);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AccountEntryResponse create(AccountEntryRequest req, Long memberId) {
|
||||
validateEntryWallets(req, memberId);
|
||||
boolean transfer = "TRANSFER".equals(req.getType());
|
||||
AccountEntry entry = AccountEntry.builder()
|
||||
.memberId(memberId)
|
||||
.entryDate(req.getEntryDate())
|
||||
.type(req.getType())
|
||||
.category(transfer ? null : req.getCategory())
|
||||
.amount(req.getAmount())
|
||||
.memo(req.getMemo())
|
||||
.walletId(req.getWalletId())
|
||||
.toWalletId(transfer ? req.getToWalletId() : null)
|
||||
.installmentMonths(installmentOf(req))
|
||||
.currency(req.getCurrency())
|
||||
.originalAmount(req.getOriginalAmount())
|
||||
.exchangeRate(req.getExchangeRate())
|
||||
.build();
|
||||
mapper.insert(entry);
|
||||
applyTags(entry.getId(), req.getTagIds(), memberId);
|
||||
pointService.awardAccountEntry(memberId);
|
||||
awardIfMonthComplete(entry.getEntryDate(), memberId);
|
||||
return toResponse(mapper.findByIdAndMember(entry.getId(), memberId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 내역의 월이 '현재 진행 중인 달'이고 그 달 모든 날에 기록이 있으면 개근 보너스(랜덤 1~100P) 지급.
|
||||
* 지난달을 소급 입력해 채우는 파밍을 막기 위해 현재 월만 인정한다(전 월 제외). 판정 실패는 저장에 영향 없음.
|
||||
*/
|
||||
private void awardIfMonthComplete(java.time.LocalDate entryDate, Long memberId) {
|
||||
if (entryDate == null) return;
|
||||
try {
|
||||
java.time.LocalDate today = java.time.LocalDate.now();
|
||||
if (entryDate.getYear() != today.getYear() || entryDate.getMonthValue() != today.getMonthValue()) return;
|
||||
int recorded = mapper.countDistinctEntryDays(memberId, entryDate.getYear(), entryDate.getMonthValue());
|
||||
if (recorded >= entryDate.lengthOfMonth()) {
|
||||
pointService.awardMonthComplete(memberId, entryDate.getYear(), entryDate.getMonthValue());
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
// 개근 판정 오류는 무시(내역 저장은 이미 완료)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AccountEntryResponse update(Long id, AccountEntryRequest req, Long memberId) {
|
||||
AccountEntry entry = mustFind(id, memberId);
|
||||
validateEntryWallets(req, memberId);
|
||||
boolean transfer = "TRANSFER".equals(req.getType());
|
||||
entry.setEntryDate(req.getEntryDate());
|
||||
entry.setType(req.getType());
|
||||
entry.setCategory(transfer ? null : req.getCategory());
|
||||
entry.setAmount(req.getAmount());
|
||||
entry.setMemo(req.getMemo());
|
||||
entry.setWalletId(req.getWalletId());
|
||||
entry.setToWalletId(transfer ? req.getToWalletId() : null);
|
||||
entry.setInstallmentMonths(installmentOf(req));
|
||||
entry.setCurrency(req.getCurrency());
|
||||
entry.setOriginalAmount(req.getOriginalAmount());
|
||||
entry.setExchangeRate(req.getExchangeRate());
|
||||
mapper.update(entry);
|
||||
|
||||
mapper.deleteEntryTagsByEntryId(id);
|
||||
applyTags(id, req.getTagIds(), memberId);
|
||||
return toResponse(mapper.findByIdAndMember(id, memberId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
mapper.deleteEntryTagsByEntryId(id);
|
||||
mapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
/* ===================== 카드 결제 알림 자동인식 ===================== */
|
||||
|
||||
/** 미확인(확인 필요) 내역 개수 */
|
||||
public int countPending(Long memberId) {
|
||||
return mapper.countPending(memberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
|
||||
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
|
||||
*/
|
||||
/**
|
||||
* 문자/푸시·자연어 한 줄 텍스트를 파싱해 폼 자동채움용 결과 반환 (저장 안 함).
|
||||
* AI 파서(Claude Haiku)가 설정돼 있으면 우선 시도("어제 스타벅스 5천원" 같은 자유 입력 처리),
|
||||
* 미설정/실패/미인식이면 기존 규칙기반 카드알림 파서로 폴백.
|
||||
*/
|
||||
public ParsedEntryResponse parseText(NotificationRequest req) {
|
||||
java.time.LocalDate today = java.time.LocalDate.now();
|
||||
java.util.Optional<ParsedEntryResponse> ai =
|
||||
aiEntryParser.parse(req.getText(), today, req.getCategoryHints(), req.getWalletHints());
|
||||
if (ai.isPresent() && ai.get().isRecognized()) {
|
||||
return ai.get();
|
||||
}
|
||||
CardNotificationParser.Parsed p = notificationParser.parse(req.getTitle(), req.getText(), today);
|
||||
return ParsedEntryResponse.builder()
|
||||
.recognized(p.isRecognized())
|
||||
.type(p.isIncome() ? "INCOME" : "EXPENSE")
|
||||
.amount(p.getAmount())
|
||||
.merchant(p.getMerchant())
|
||||
.date(p.getDate())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 통계 화면 집계 → AI 재무 코멘트.
|
||||
* - PREMIUM: 무제한.
|
||||
* - FREE + ai_stat_credits > 0: 크레딧 1 차감 후 호출.
|
||||
* - FREE + credits == 0: 402 에러(프론트가 상점으로 유도).
|
||||
*/
|
||||
@Transactional
|
||||
public com.sb.web.account.dto.AiCommentResponse aiComment(
|
||||
com.sb.web.account.dto.AiCommentRequest req, SessionUser current) {
|
||||
if ("FREE".equals(current.getPlan())) {
|
||||
int credits = memberMapper.getAiStatCredits(current.getId());
|
||||
if (credits <= 0) {
|
||||
throw new ApiException(HttpStatus.PAYMENT_REQUIRED, "AI_STAT_CREDIT_EMPTY");
|
||||
}
|
||||
memberMapper.decrementAiStatCredit(current.getId());
|
||||
}
|
||||
try {
|
||||
String json = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(req);
|
||||
return aiEntryParser.financeComment(json);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
return com.sb.web.account.dto.AiCommentResponse.builder()
|
||||
.bullets(java.util.List.of()).tips(java.util.List.of()).build();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
|
||||
CardNotificationParser.Parsed p =
|
||||
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
|
||||
if (!p.isRecognized() || p.getAmount() <= 0) {
|
||||
return null; // 결제/이체 알림 아님 → 무시
|
||||
}
|
||||
// 중복방지
|
||||
if (p.getNotifKey() != null && mapper.findByMemberAndNotifKey(memberId, p.getNotifKey()) != null) {
|
||||
return null;
|
||||
}
|
||||
// 카드 결제 → 카드(CARD) 매칭 / 은행 입출금·이체 → 은행(BANK) 매칭
|
||||
String walletType = p.isBankTransfer() ? "BANK" : "CARD";
|
||||
Long walletId = matchWalletId(p.getIssuer(), memberId, walletType);
|
||||
|
||||
// 분류 자동학습: 같은 메모(가맹점) 키워드로 과거에 확정한 분류가 있으면 그대로 채워준다.
|
||||
String type = p.isIncome() ? "INCOME" : "EXPENSE";
|
||||
String merchant = p.getMerchant();
|
||||
String learnedCategory = (merchant != null && !merchant.isBlank())
|
||||
? mapper.findLearnedCategory(memberId, type, merchant)
|
||||
: null;
|
||||
|
||||
AccountEntry entry = AccountEntry.builder()
|
||||
.memberId(memberId)
|
||||
.entryDate(p.getDate() != null ? p.getDate() : java.time.LocalDate.now())
|
||||
.type(type)
|
||||
.category(learnedCategory)
|
||||
.amount(p.getAmount())
|
||||
.memo(merchant)
|
||||
.walletId(walletId)
|
||||
.toWalletId(null)
|
||||
.installmentMonths(null)
|
||||
.pending(true)
|
||||
.notifKey(p.getNotifKey())
|
||||
.build();
|
||||
mapper.insert(entry);
|
||||
return toResponse(mapper.findByIdAndMember(entry.getId(), memberId));
|
||||
}
|
||||
|
||||
/** 미확인 내역 확정 (분류/계좌 보정 후 pending 해제) */
|
||||
@Transactional
|
||||
public AccountEntryResponse confirm(Long id, ConfirmRequest req, Long memberId) {
|
||||
AccountEntry e = mustFind(id, memberId);
|
||||
if (!Boolean.TRUE.equals(e.getPending())) {
|
||||
return toResponse(e); // 이미 확정된 건
|
||||
}
|
||||
String category = req != null ? req.getCategory() : null;
|
||||
Long walletId = req != null ? req.getWalletId() : null;
|
||||
mapper.confirm(id, memberId, category, walletId);
|
||||
return toResponse(mapper.findByIdAndMember(id, memberId));
|
||||
}
|
||||
|
||||
/** 카드사/은행명으로 등록된 해당 type(CARD/BANK) 지갑 id 찾기 (issuer/name 유연 매칭).
|
||||
* 미매칭이라도 그 type 지갑이 정확히 1개면 그 지갑으로 기본 매칭. */
|
||||
private Long matchWalletId(String issuer, Long memberId, String walletType) {
|
||||
List<Wallet> wallets = walletMapper.findByMember(memberId).stream()
|
||||
.filter(w -> walletType.equals(w.getType()))
|
||||
.toList();
|
||||
if (issuer != null) {
|
||||
Long matched = wallets.stream()
|
||||
.filter(w -> overlaps(w.getIssuer(), issuer) || overlaps(w.getName(), issuer))
|
||||
.map(Wallet::getId)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (matched != null) return matched;
|
||||
}
|
||||
return wallets.size() == 1 ? wallets.get(0).getId() : null;
|
||||
}
|
||||
|
||||
/** 표기 차이를 흡수한 카드사 매칭 (KB국민 ↔ 국민 ↔ 국민카드 ↔ KB카드 등) */
|
||||
private boolean overlaps(String walletField, String issuer) {
|
||||
return CardIssuerMatcher.matches(walletField, issuer);
|
||||
}
|
||||
|
||||
/* ===================== 상환/납부 ===================== */
|
||||
|
||||
/** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자·연회비는 지출로 분리 생성 */
|
||||
@Transactional
|
||||
public void repay(RepaymentRequest req, Long memberId) {
|
||||
long principal = req.getPrincipal() != null ? req.getPrincipal() : 0L;
|
||||
long interest = req.getInterest() != null ? req.getInterest() : 0L;
|
||||
long annualFee = req.getAnnualFee() != null ? req.getAnnualFee() : 0L;
|
||||
if (principal <= 0 && interest <= 0 && annualFee <= 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "원금·이자·연회비 중 하나는 입력하세요.");
|
||||
}
|
||||
requireOwnedWallet(req.getFromWalletId(), memberId);
|
||||
Wallet target = walletMapper.findByIdAndMember(req.getTargetWalletId(), memberId);
|
||||
if (target == null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "대상 계좌가 올바르지 않습니다.");
|
||||
}
|
||||
if (!"LOAN".equals(target.getType()) && !"CARD".equals(target.getType())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "상환 대상은 대출/카드만 가능합니다.");
|
||||
}
|
||||
if (annualFee > 0 && !"CARD".equals(target.getType())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "연회비는 카드 상환에만 입력할 수 있습니다.");
|
||||
}
|
||||
if (req.getFromWalletId().equals(req.getTargetWalletId())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "출금/대상 계좌가 같을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 원금: 이체 (출금 → 대상, 부채 감소)
|
||||
if (principal > 0) {
|
||||
mapper.insert(AccountEntry.builder()
|
||||
.memberId(memberId)
|
||||
.entryDate(req.getEntryDate())
|
||||
.type("TRANSFER")
|
||||
.amount(principal)
|
||||
.walletId(req.getFromWalletId())
|
||||
.toWalletId(req.getTargetWalletId())
|
||||
.memo(req.getMemo() != null ? req.getMemo() : "원금상환")
|
||||
.build());
|
||||
}
|
||||
// 이자: 지출 (출금 계좌에서, 분류=이자)
|
||||
if (interest > 0) {
|
||||
mapper.insert(AccountEntry.builder()
|
||||
.memberId(memberId)
|
||||
.entryDate(req.getEntryDate())
|
||||
.type("EXPENSE")
|
||||
.category("이자")
|
||||
.amount(interest)
|
||||
.walletId(req.getFromWalletId())
|
||||
.memo(req.getMemo() != null ? req.getMemo() : "이자")
|
||||
.build());
|
||||
}
|
||||
// 연회비: 지출 (카드, 출금 계좌에서, 분류=연회비)
|
||||
if (annualFee > 0) {
|
||||
mapper.insert(AccountEntry.builder()
|
||||
.memberId(memberId)
|
||||
.entryDate(req.getEntryDate())
|
||||
.type("EXPENSE")
|
||||
.category("연회비")
|
||||
.amount(annualFee)
|
||||
.walletId(req.getFromWalletId())
|
||||
.memo(req.getMemo() != null ? req.getMemo() : "연회비")
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 계좌/카드 (사용자별) ===================== */
|
||||
|
||||
public List<WalletResponse> listWallets(Long memberId) {
|
||||
Map<Long, Long> balances = balanceMap(memberId);
|
||||
Map<Long, InvestService.WalletInvest> inv = investService.valuationByWallet(memberId);
|
||||
return walletMapper.findByMember(memberId).stream()
|
||||
.map(w -> {
|
||||
long cash = balances.getOrDefault(w.getId(), 0L);
|
||||
WalletResponse r = WalletResponse.from(w, cash);
|
||||
if ("INVEST".equals(w.getType())) {
|
||||
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
|
||||
long deposit = cash + wi.cashDelta; // 예수금
|
||||
if (w.getCurrentValue() != null) {
|
||||
// 평가액 직접입력형(퇴직연금·연금 등): 입력한 평가액을 총평가로 사용
|
||||
long total = w.getCurrentValue();
|
||||
r.setBalance(total);
|
||||
r.setInvestedAmount(cash); // 누적 적립원금(순입금)
|
||||
r.setDeposit(deposit);
|
||||
r.setStockValue(total - deposit);
|
||||
r.setValuationGain(total - cash);
|
||||
r.setManualValuation(true);
|
||||
} else {
|
||||
long total = deposit + wi.stockEval; // 총평가
|
||||
r.setBalance(total);
|
||||
r.setInvestedAmount(cash); // 순입금(투입원금)
|
||||
r.setDeposit(deposit);
|
||||
r.setStockValue(wi.stockEval);
|
||||
r.setValuationGain(total - cash); // 실현+평가 손익
|
||||
r.setManualValuation(false);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 순자산 = 총자산(BANK/CASH/INVEST) − 총부채(CARD/LOAN) */
|
||||
public NetWorthResponse netWorth(Long memberId) {
|
||||
Map<Long, Long> balances = balanceMap(memberId);
|
||||
Map<Long, InvestService.WalletInvest> inv = investService.valuationByWallet(memberId);
|
||||
long assets = 0;
|
||||
long liabilities = 0;
|
||||
for (Wallet w : walletMapper.findByMember(memberId)) {
|
||||
long bal = balances.getOrDefault(w.getId(), 0L);
|
||||
if ("INVEST".equals(w.getType())) {
|
||||
if (w.getCurrentValue() != null) {
|
||||
// 평가액 직접입력형(퇴직연금·연금 등): 입력한 평가액을 자산으로
|
||||
assets += w.getCurrentValue();
|
||||
} else {
|
||||
// 투자: 예수금(현금+매매증감) + 주식 평가금액
|
||||
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
|
||||
assets += bal + wi.cashDelta + wi.stockEval;
|
||||
}
|
||||
} else if ("BANK".equals(w.getType()) || "CASH".equals(w.getType())) {
|
||||
assets += bal;
|
||||
} else {
|
||||
liabilities += -bal; // 부채는 음수 잔액 → 양수 부채로 표시
|
||||
}
|
||||
}
|
||||
return NetWorthResponse.builder()
|
||||
.totalAssets(assets)
|
||||
.totalLiabilities(liabilities)
|
||||
.netWorth(assets - liabilities)
|
||||
.build();
|
||||
}
|
||||
|
||||
// 무료 회원 계좌 등록 한도: 종류(은행·현금·카드·대출·증권·마이너스)별 기본 2개.
|
||||
// 포인트 상점(추후)에서 영구 보너스 슬롯을 구매하면 종류별 최대 5개까지 확장 가능.
|
||||
public static final int FREE_WALLET_BASE_LIMIT = 2;
|
||||
public static final int FREE_WALLET_MAX_LIMIT = 5;
|
||||
|
||||
/** 무료 회원의 해당 타입 등록 한도 = 기본 2 + 포인트 상점 구매 슬롯, 최대 5. */
|
||||
private int freeWalletLimit(Long memberId, String type) {
|
||||
int bonus = memberMapper.getExtraWalletSlots(memberId);
|
||||
return Math.min(FREE_WALLET_BASE_LIMIT + bonus, FREE_WALLET_MAX_LIMIT);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WalletResponse createWallet(WalletRequest req, Long memberId, boolean premium) {
|
||||
// 유료(또는 관리자)는 무제한. 무료는 종류별 한도 검증.
|
||||
if (!premium) {
|
||||
int limit = freeWalletLimit(memberId, req.getType());
|
||||
if (walletMapper.countByType(memberId, req.getType()) >= limit) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST,
|
||||
"무료 회원은 종류별 계좌를 " + limit + "개까지 등록할 수 있어요. 유료로 업그레이드하면 무제한이에요.");
|
||||
}
|
||||
}
|
||||
Wallet wallet = toWallet(req);
|
||||
wallet.setMemberId(memberId);
|
||||
Integer max = walletMapper.maxSortOrder(memberId, req.getType());
|
||||
wallet.setSortOrder(max == null ? 0 : max + 1); // 같은 타입의 맨 뒤
|
||||
walletMapper.insert(wallet);
|
||||
return WalletResponse.from(wallet, wallet.getOpeningBalance()); // 신규 → 잔액=초기잔액
|
||||
}
|
||||
|
||||
/** 드래그앤드랍 순서 변경: 전달된 id 순서대로 sort_order 재설정 (본인·동일 타입만) */
|
||||
@Transactional
|
||||
public List<WalletResponse> reorderWallets(Long memberId, String type, List<Long> ids) {
|
||||
int i = 0;
|
||||
for (Long id : ids) {
|
||||
Wallet w = walletMapper.findByIdAndMember(id, memberId);
|
||||
if (w != null && w.getType().equals(type)) {
|
||||
walletMapper.updateSortOrder(id, memberId, i++);
|
||||
}
|
||||
}
|
||||
return listWallets(memberId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WalletResponse updateWallet(Long id, WalletRequest req, Long memberId) {
|
||||
Wallet wallet = mustFindWallet(id, memberId);
|
||||
wallet.setType(req.getType());
|
||||
wallet.setName(req.getName().trim());
|
||||
wallet.setIssuer(blankToNull(req.getIssuer()));
|
||||
wallet.setAccountNumber(("BANK".equals(req.getType()) || "MINUS".equals(req.getType())) ? blankToNull(req.getAccountNumber()) : null);
|
||||
wallet.setCardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null);
|
||||
wallet.setOpeningBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L);
|
||||
wallet.setOpeningDate(req.getOpeningDate());
|
||||
wallet.setCurrentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null);
|
||||
boolean isLoan = "LOAN".equals(req.getType());
|
||||
wallet.setLoanAmount(isLoan ? req.getLoanAmount() : null);
|
||||
wallet.setLoanRate(isLoan ? req.getLoanRate() : null);
|
||||
wallet.setLoanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null);
|
||||
wallet.setLoanMonths(isLoan ? req.getLoanMonths() : null);
|
||||
wallet.setLoanStart(isLoan ? req.getLoanStart() : null);
|
||||
wallet.setLoanPayment(isLoan ? req.getLoanPayment() : null);
|
||||
walletMapper.update(wallet);
|
||||
return WalletResponse.from(wallet, balanceMap(memberId).getOrDefault(id, wallet.getOpeningBalance()));
|
||||
}
|
||||
|
||||
private Map<Long, Long> balanceMap(Long memberId) {
|
||||
Map<Long, Long> map = new HashMap<>();
|
||||
for (Map<String, Object> row : walletMapper.findBalances(memberId)) {
|
||||
Long id = ((Number) row.get("id")).longValue();
|
||||
Object b = row.get("balance");
|
||||
map.put(id, b == null ? 0L : ((Number) b).longValue());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteWallet(Long id, Long memberId) {
|
||||
mustFindWallet(id, memberId);
|
||||
walletMapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
/** 특정 계좌와 연관된 내역 목록 */
|
||||
public List<AccountEntryResponse> listEntriesByWallet(Long walletId, Long memberId) {
|
||||
mustFindWallet(walletId, memberId);
|
||||
return mapper.findByWalletAndMember(memberId, walletId).stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Wallet toWallet(WalletRequest req) {
|
||||
boolean isLoan = "LOAN".equals(req.getType());
|
||||
return Wallet.builder()
|
||||
.type(req.getType())
|
||||
.name(req.getName().trim())
|
||||
.issuer(blankToNull(req.getIssuer()))
|
||||
.accountNumber(("BANK".equals(req.getType()) || "MINUS".equals(req.getType())) ? blankToNull(req.getAccountNumber()) : null)
|
||||
.cardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null)
|
||||
.openingBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L)
|
||||
.openingDate(req.getOpeningDate())
|
||||
.currentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null)
|
||||
.loanAmount(isLoan ? req.getLoanAmount() : null)
|
||||
.loanRate(isLoan ? req.getLoanRate() : null)
|
||||
.loanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null)
|
||||
.loanMonths(isLoan ? req.getLoanMonths() : null)
|
||||
.loanStart(isLoan ? req.getLoanStart() : null)
|
||||
.loanPayment(isLoan ? req.getLoanPayment() : null)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Wallet mustFindWallet(Long id, Long memberId) {
|
||||
Wallet wallet = walletMapper.findByIdAndMember(id, memberId);
|
||||
if (wallet == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "계좌를 찾을 수 없습니다.");
|
||||
}
|
||||
return wallet;
|
||||
}
|
||||
|
||||
/** 내역의 계좌 검증. 이체는 출금/입금 계좌 필수·본인·서로 다름, 그 외는 선택 계좌만 검증 */
|
||||
private void validateEntryWallets(AccountEntryRequest req, Long memberId) {
|
||||
if ("TRANSFER".equals(req.getType())) {
|
||||
if (req.getWalletId() == null || req.getToWalletId() == null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "이체는 출금/입금 계좌를 모두 선택해야 합니다.");
|
||||
}
|
||||
if (req.getWalletId().equals(req.getToWalletId())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "출금/입금 계좌가 같을 수 없습니다.");
|
||||
}
|
||||
requireOwnedWallet(req.getWalletId(), memberId);
|
||||
requireOwnedWallet(req.getToWalletId(), memberId);
|
||||
} else if (req.getWalletId() != null) {
|
||||
requireOwnedWallet(req.getWalletId(), memberId);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireOwnedWallet(Long walletId, Long memberId) {
|
||||
if (walletMapper.findByIdAndMember(walletId, memberId) == null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "선택한 계좌가 올바르지 않습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
|
||||
/* ===================== 태그 (사용자별) ===================== */
|
||||
|
||||
public List<AccountTagResponse> listTags(Long memberId) {
|
||||
return tagMapper.findByMember(memberId).stream().map(AccountTagResponse::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AccountTagResponse createTag(AccountTagRequest req, Long memberId) {
|
||||
String name = req.getName().trim();
|
||||
if (tagMapper.countByName(memberId, name, null) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다.");
|
||||
}
|
||||
Integer max = tagMapper.maxSortOrder(memberId);
|
||||
AccountTag tag = AccountTag.builder()
|
||||
.memberId(memberId)
|
||||
.name(name)
|
||||
.sortOrder(max == null ? 0 : max + 1)
|
||||
.build();
|
||||
tagMapper.insert(tag);
|
||||
return AccountTagResponse.from(tag);
|
||||
}
|
||||
|
||||
/** 태그 순서 변경 — ids 순서대로 sort_order 재설정 */
|
||||
@Transactional
|
||||
public List<AccountTagResponse> reorderTags(Long memberId, List<Long> ids) {
|
||||
int i = 0;
|
||||
for (Long id : ids) {
|
||||
if (tagMapper.findByIdAndMember(id, memberId) != null) {
|
||||
tagMapper.updateSortOrder(id, memberId, i++);
|
||||
}
|
||||
}
|
||||
return listTags(memberId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AccountTagResponse updateTag(Long id, AccountTagRequest req, Long memberId) {
|
||||
AccountTag tag = mustFindTag(id, memberId);
|
||||
String name = req.getName().trim();
|
||||
if (tagMapper.countByName(memberId, name, id) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다.");
|
||||
}
|
||||
tag.setName(name);
|
||||
tagMapper.update(tag);
|
||||
return AccountTagResponse.from(tag);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTag(Long id, Long memberId) {
|
||||
mustFindTag(id, memberId);
|
||||
tagMapper.deleteEntryLinksByTagId(id);
|
||||
tagMapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
/* ===================== 내부 ===================== */
|
||||
|
||||
private AccountEntryResponse toResponse(AccountEntry entry) {
|
||||
return AccountEntryResponse.from(entry, mapper.findTagNamesByEntryId(entry.getId()));
|
||||
}
|
||||
|
||||
/** 할부 개월수 정규화: 지출(EXPENSE)이고 2개월 이상일 때만 저장, 그 외(일시불·수입·이체)는 null */
|
||||
private Integer installmentOf(AccountEntryRequest req) {
|
||||
Integer m = req.getInstallmentMonths();
|
||||
if (!"EXPENSE".equals(req.getType()) || m == null || m < 2) {
|
||||
return null;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 선택된 태그 ID 중 해당 사용자 소유로 존재하는 것만 연결 */
|
||||
private void applyTags(Long entryId, List<Long> tagIds, Long memberId) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> distinct = tagIds.stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (distinct.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Long tagId : tagMapper.findExistingIds(memberId, distinct)) {
|
||||
mapper.insertEntryTag(entryId, tagId);
|
||||
}
|
||||
}
|
||||
|
||||
private AccountEntry mustFind(Long id, Long memberId) {
|
||||
AccountEntry entry = mapper.findByIdAndMember(id, memberId);
|
||||
if (entry == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "내역을 찾을 수 없습니다.");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private AccountTag mustFindTag(Long id, Long memberId) {
|
||||
AccountTag tag = tagMapper.findByIdAndMember(id, memberId);
|
||||
if (tag == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "태그를 찾을 수 없습니다.");
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.mapper.AccountSettingMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 월 예상 수입 설정 서비스 (월별).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AccountSettingService {
|
||||
|
||||
private final AccountSettingMapper settingMapper;
|
||||
|
||||
public Long getExpectedIncome(Long memberId, int year, int month) {
|
||||
return settingMapper.findExpectedIncome(memberId, year, month);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void setExpectedIncome(Long memberId, int year, int month, Long expectedIncome) {
|
||||
settingMapper.upsertExpectedIncome(memberId, year, month, expectedIncome);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.dto.AccountEntryRequest;
|
||||
import com.sb.web.account.dto.AccountTagRequest;
|
||||
import com.sb.web.account.dto.BudgetRequest;
|
||||
import com.sb.web.account.dto.CategoryRequest;
|
||||
import com.sb.web.account.dto.QuickEntryRequest;
|
||||
import com.sb.web.account.dto.RecurringRequest;
|
||||
import com.sb.web.account.dto.RestoreRequest;
|
||||
import com.sb.web.account.dto.WalletRequest;
|
||||
import com.sb.web.account.mapper.BackupMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 데이터 가져오기(복구) — 기존 데이터를 전부 삭제하고 백업(이름 기준)을 새로 재생성한다.
|
||||
* 전체를 한 트랜잭션으로 처리해 중간 실패 시 롤백(기존 데이터 보존)된다.
|
||||
* ※ 투자 종목/매매내역은 백업에 포함되지 않아 복구 시 삭제된다(평가액 계좌 자체는 복원).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BackupService {
|
||||
|
||||
private final BackupMapper backupMapper;
|
||||
private final AccountService accountService;
|
||||
private final CategoryService categoryService;
|
||||
private final RecurringService recurringService;
|
||||
private final BudgetService budgetService;
|
||||
private final QuickEntryService quickEntryService;
|
||||
|
||||
@Transactional
|
||||
public void deleteAll(Long memberId) {
|
||||
backupMapper.deleteEntryTags(memberId);
|
||||
backupMapper.deleteEntries(memberId);
|
||||
backupMapper.deleteRecurrings(memberId);
|
||||
backupMapper.deleteBudgets(memberId);
|
||||
backupMapper.deleteBudgetIncome(memberId);
|
||||
backupMapper.deleteBudgetOnetime(memberId);
|
||||
backupMapper.deleteQuickEntries(memberId);
|
||||
backupMapper.deleteInvestTrades(memberId);
|
||||
backupMapper.deleteInvestHoldings(memberId);
|
||||
backupMapper.deleteCategories(memberId);
|
||||
backupMapper.deleteTags(memberId);
|
||||
backupMapper.deleteWallets(memberId);
|
||||
log.info("[deleteAll] member={} — all account data deleted", memberId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void restore(Long memberId, RestoreRequest req) {
|
||||
if (isEmpty(req)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "복구할 데이터가 비어 있습니다. 백업 파일을 확인해주세요.");
|
||||
}
|
||||
|
||||
// 1) 기존 데이터 전부 삭제 (FK 안전 순서)
|
||||
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);
|
||||
|
||||
// 2) 계좌 → 원본 id(oldId) → 새 id (같은 이름 계좌가 있어도 정확)
|
||||
Map<Long, Long> walletMap = new HashMap<>();
|
||||
if (req.getWallets() != null) {
|
||||
for (RestoreRequest.Wal w : req.getWallets()) {
|
||||
if (w == null || w.getName() == null) continue;
|
||||
WalletRequest wr = new WalletRequest();
|
||||
wr.setType(w.getType());
|
||||
wr.setName(w.getName());
|
||||
wr.setIssuer(w.getIssuer());
|
||||
wr.setAccountNumber(w.getAccountNumber());
|
||||
wr.setCardType(w.getCardType());
|
||||
wr.setOpeningBalance(w.getOpeningBalance());
|
||||
wr.setOpeningDate(w.getOpeningDate());
|
||||
wr.setCurrentValue(w.getCurrentValue());
|
||||
// 복원은 사용자 본인 데이터 복구이므로 무료 한도(종류별 2개)를 적용하지 않는다.
|
||||
Long newId = accountService.createWallet(wr, memberId, true).getId();
|
||||
if (w.getOldId() != null) walletMap.put(w.getOldId(), newId);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 태그 → 이름→새 ID
|
||||
Map<String, Long> tagMap = new HashMap<>();
|
||||
if (req.getTags() != null) {
|
||||
for (String name : req.getTags()) {
|
||||
if (name == null || name.isBlank()) continue;
|
||||
AccountTagRequest tr = new AccountTagRequest();
|
||||
tr.setName(name.trim());
|
||||
tagMap.put(name.trim(), accountService.createTag(tr, memberId).getId());
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 분류 — 대분류 먼저, 소분류는 부모 이름으로 매핑 ("TYPE|name" → id)
|
||||
Map<String, Long> catMap = new HashMap<>();
|
||||
if (req.getCategories() != null) {
|
||||
for (RestoreRequest.Cat c : req.getCategories()) {
|
||||
if (c.getName() == null || (c.getParent() != null && !c.getParent().isBlank())) continue;
|
||||
CategoryRequest cr = new CategoryRequest();
|
||||
cr.setType(c.getType());
|
||||
cr.setName(c.getName());
|
||||
catMap.put(key(c.getType(), c.getName()), categoryService.create(cr, memberId).getId());
|
||||
}
|
||||
for (RestoreRequest.Cat c : req.getCategories()) {
|
||||
if (c.getName() == null || c.getParent() == null || c.getParent().isBlank()) continue;
|
||||
CategoryRequest cr = new CategoryRequest();
|
||||
cr.setType(c.getType());
|
||||
cr.setName(c.getName());
|
||||
cr.setParentId(catMap.get(key(c.getType(), c.getParent())));
|
||||
categoryService.create(cr, memberId);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) 내역
|
||||
if (req.getEntries() != null) {
|
||||
for (RestoreRequest.Entry e : req.getEntries()) {
|
||||
if (e.getEntryDate() == null || e.getType() == null) continue;
|
||||
Long wid = walletMap.get(e.getWalletId());
|
||||
Long twid = walletMap.get(e.getToWalletId());
|
||||
if (badTransfer(e.getType(), wid, twid)) continue; // 이체 계좌가 없거나 같으면 건너뜀
|
||||
AccountEntryRequest er = new AccountEntryRequest();
|
||||
er.setEntryDate(e.getEntryDate());
|
||||
er.setType(e.getType());
|
||||
er.setCategory(e.getCategory());
|
||||
er.setAmount(e.getAmount());
|
||||
er.setMemo(e.getMemo());
|
||||
er.setWalletId(wid);
|
||||
er.setToWalletId(twid);
|
||||
er.setInstallmentMonths(e.getInstallmentMonths());
|
||||
if (e.getTags() != null) {
|
||||
er.setTagIds(e.getTags().stream().map(tagMap::get).filter(Objects::nonNull).toList());
|
||||
}
|
||||
accountService.create(er, memberId);
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 고정지출
|
||||
if (req.getRecurrings() != null) {
|
||||
for (RestoreRequest.Recur r : req.getRecurrings()) {
|
||||
if (r.getTitle() == null) continue;
|
||||
Long wid = walletMap.get(r.getWalletId());
|
||||
Long twid = walletMap.get(r.getToWalletId());
|
||||
if (badTransfer(r.getType(), wid, twid)) continue;
|
||||
RecurringRequest rr = new RecurringRequest();
|
||||
rr.setTitle(r.getTitle());
|
||||
rr.setType(r.getType());
|
||||
rr.setAmount(r.getAmount());
|
||||
rr.setCategory(r.getCategory());
|
||||
rr.setMemo(r.getMemo());
|
||||
rr.setWalletId(wid);
|
||||
rr.setToWalletId(twid);
|
||||
rr.setFrequency(r.getFrequency());
|
||||
rr.setDayOfMonth(r.getDayOfMonth());
|
||||
rr.setDayOfWeek(r.getDayOfWeek());
|
||||
rr.setMonthOfYear(r.getMonthOfYear());
|
||||
rr.setStartDate(r.getStartDate());
|
||||
rr.setEndDate(r.getEndDate());
|
||||
rr.setActive(r.getActive() == null || r.getActive());
|
||||
// 복원은 본인 데이터 복구이므로 무료 한도 미적용
|
||||
recurringService.create(rr, memberId, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 예산 — 복구 시 현재 월 예산으로 등록(백업엔 월 정보 없음)
|
||||
if (req.getBudgets() != null) {
|
||||
java.time.LocalDate today = java.time.LocalDate.now();
|
||||
for (BudgetRequest b : req.getBudgets()) {
|
||||
if (b == null || b.getCategory() == null) continue;
|
||||
budgetService.create(b, memberId, today.getYear(), today.getMonthValue());
|
||||
}
|
||||
}
|
||||
|
||||
// 8) 자주 쓰는 내역
|
||||
if (req.getQuickEntries() != null) {
|
||||
for (RestoreRequest.Quick q : req.getQuickEntries()) {
|
||||
if (q.getType() == null) continue;
|
||||
QuickEntryRequest qr = new QuickEntryRequest();
|
||||
qr.setLabel(q.getLabel());
|
||||
qr.setType(q.getType());
|
||||
qr.setCategory(q.getCategory());
|
||||
qr.setAmount(q.getAmount());
|
||||
qr.setMemo(q.getMemo());
|
||||
qr.setWalletId(walletMap.get(q.getWalletId()));
|
||||
quickEntryService.create(qr, memberId);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[restore] member={} wallets={} entries={} restored",
|
||||
memberId, walletMap.size(), req.getEntries() == null ? 0 : req.getEntries().size());
|
||||
}
|
||||
|
||||
private static String key(String type, String name) {
|
||||
return type + "|" + name;
|
||||
}
|
||||
|
||||
/** 이체인데 출금/입금 계좌가 없거나 같으면 복구에서 제외(검증 오류로 전체 롤백 방지) */
|
||||
private static boolean badTransfer(String type, Long walletId, Long toWalletId) {
|
||||
return "TRANSFER".equals(type) && (walletId == null || toWalletId == null || walletId.equals(toWalletId));
|
||||
}
|
||||
|
||||
private boolean isEmpty(RestoreRequest r) {
|
||||
return empty(r.getWallets()) && empty(r.getCategories()) && empty(r.getTags())
|
||||
&& empty(r.getEntries()) && empty(r.getRecurrings()) && empty(r.getBudgets())
|
||||
&& empty(r.getQuickEntries());
|
||||
}
|
||||
|
||||
private boolean empty(List<?> l) {
|
||||
return l == null || l.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.Budget;
|
||||
import com.sb.web.account.dto.BudgetPeriodStat;
|
||||
import com.sb.web.account.dto.BudgetRequest;
|
||||
import com.sb.web.account.dto.BudgetResponse;
|
||||
import com.sb.web.account.dto.BudgetStatus;
|
||||
import com.sb.web.account.mapper.AccountEntryMapper;
|
||||
import com.sb.web.account.mapper.BudgetMapper;
|
||||
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.time.Year;
|
||||
import java.time.YearMonth;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* 예산 서비스. 사용자별 카테고리 예산 CRUD + 월 예산(실제 일수 반영) 대비 지출 현황.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BudgetService {
|
||||
|
||||
private final BudgetMapper budgetMapper;
|
||||
private final AccountEntryMapper entryMapper;
|
||||
|
||||
public List<BudgetResponse> list(Long memberId, int year, int month) {
|
||||
return budgetMapper.findByMember(memberId, year, month).stream().map(BudgetResponse::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BudgetResponse create(BudgetRequest req, Long memberId, int year, int month) {
|
||||
String category = req.getCategory().trim();
|
||||
if (budgetMapper.countByCategory(memberId, category, null, year, month) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
|
||||
}
|
||||
Budget budget = toBudget(req, category);
|
||||
budget.setMemberId(memberId);
|
||||
budget.setYear(year);
|
||||
budget.setMonth(month);
|
||||
budgetMapper.insert(budget);
|
||||
return BudgetResponse.from(budget);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BudgetResponse update(Long id, BudgetRequest req, Long memberId) {
|
||||
Budget existing = mustFind(id, memberId);
|
||||
String category = req.getCategory().trim();
|
||||
if (budgetMapper.countByCategory(memberId, category, id, existing.getYear(), existing.getMonth()) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
|
||||
}
|
||||
Budget budget = toBudget(req, category);
|
||||
budget.setId(id);
|
||||
budget.setMemberId(memberId);
|
||||
budget.setYear(existing.getYear());
|
||||
budget.setMonth(existing.getMonth());
|
||||
budgetMapper.update(budget);
|
||||
return BudgetResponse.from(budget);
|
||||
}
|
||||
|
||||
/** fromYear/fromMonth 예산을 toYear/toMonth 로 복사(같은 분류 덮어씀). 복사된 건수 반환 */
|
||||
@Transactional
|
||||
public int copy(Long memberId, int fromYear, int fromMonth, int toYear, int toMonth) {
|
||||
return budgetMapper.copyMonth(memberId, fromYear, fromMonth, toYear, toMonth);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
budgetMapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
/** 해당 월의 예산 대비 지출 현황 (고정 예산은 그 달 실제 일수 반영) */
|
||||
public List<BudgetStatus> status(Long memberId, int year, int month) {
|
||||
int daysInMonth = YearMonth.of(year, month).lengthOfMonth();
|
||||
|
||||
Map<String, Long> spentByCategory = new HashMap<>();
|
||||
for (Map<String, Object> row : entryMapper.sumExpenseByCategory(memberId, year, month)) {
|
||||
Object cat = row.get("category");
|
||||
long total = ((Number) row.get("total")).longValue();
|
||||
spentByCategory.put(cat == null ? "" : cat.toString(), total);
|
||||
}
|
||||
|
||||
List<BudgetStatus> result = new ArrayList<>();
|
||||
for (Budget b : budgetMapper.findByMember(memberId, year, month)) {
|
||||
long monthlyBudget = monthlyBudget(b, daysInMonth);
|
||||
long spent = spentByCategory.getOrDefault(b.getCategory(), 0L);
|
||||
result.add(BudgetStatus.builder()
|
||||
.id(b.getId())
|
||||
.category(b.getCategory())
|
||||
.fixed(Boolean.TRUE.equals(b.getFixed()))
|
||||
.monthlyBudget(monthlyBudget)
|
||||
.spent(spent)
|
||||
.remaining(monthlyBudget - spent)
|
||||
.build());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 기간 버킷(일/주/월/년)별 예산 대비 지출 (대시보드 막대그래프용) */
|
||||
public List<BudgetPeriodStat> periodStats(Long memberId, String unitRaw, Integer year, Integer month) {
|
||||
String unit = switch (unitRaw == null ? "" : unitRaw.toUpperCase()) {
|
||||
case "DAY", "WEEK", "YEAR" -> unitRaw.toUpperCase();
|
||||
default -> "MONTH";
|
||||
};
|
||||
int y = year != null ? year : Year.now().getValue();
|
||||
int m = month != null ? month : 1;
|
||||
|
||||
// 기간 차트는 선택 월의 예산을 기준으로 각 버킷에 적용(월별 예산 도입 전 동작과 동일)
|
||||
List<Budget> budgets = budgetMapper.findByMember(memberId, y, m);
|
||||
Map<String, Long> expense = new HashMap<>();
|
||||
for (Map<String, Object> row : entryMapper.statsByPeriod(memberId, unit, year, month)) {
|
||||
expense.put(String.valueOf(((Number) row.get("bucket")).longValue()),
|
||||
((Number) row.get("expense")).longValue());
|
||||
}
|
||||
|
||||
List<BudgetPeriodStat> result = new ArrayList<>();
|
||||
switch (unit) {
|
||||
case "DAY" -> {
|
||||
int days = YearMonth.of(y, m).lengthOfMonth();
|
||||
long dayBudget = sum(budgets, b -> dayBudget(b, y, m));
|
||||
for (int d = 1; d <= days; d++) result.add(stat(String.valueOf(d), dayBudget, expense));
|
||||
}
|
||||
case "WEEK" -> {
|
||||
int days = YearMonth.of(y, m).lengthOfMonth();
|
||||
int weeks = (int) Math.ceil(days / 7.0);
|
||||
long weekBudget = sum(budgets, b -> weekBudget(b, y, m));
|
||||
for (int w = 1; w <= weeks; w++) result.add(stat(String.valueOf(w), weekBudget, expense));
|
||||
}
|
||||
case "YEAR" -> {
|
||||
TreeSet<Integer> years = new TreeSet<>();
|
||||
for (String k : expense.keySet()) years.add(Integer.parseInt(k));
|
||||
if (years.isEmpty()) years.add(y);
|
||||
for (int yr : years) {
|
||||
long yb = sum(budgets, b -> yearBudget(b, yr));
|
||||
result.add(stat(String.valueOf(yr), yb, expense));
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
for (int mm = 1; mm <= 12; mm++) {
|
||||
final int month0 = mm;
|
||||
long mb = sum(budgets, b -> monthlyBudget(b, YearMonth.of(y, month0).lengthOfMonth()));
|
||||
result.add(stat(String.valueOf(mm), mb, expense));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private interface BudgetFn {
|
||||
long apply(Budget b);
|
||||
}
|
||||
|
||||
private long sum(List<Budget> budgets, BudgetFn fn) {
|
||||
long total = 0;
|
||||
for (Budget b : budgets) total += fn.apply(b);
|
||||
return total;
|
||||
}
|
||||
|
||||
private BudgetPeriodStat stat(String bucket, long budget, Map<String, Long> expense) {
|
||||
return BudgetPeriodStat.builder().bucket(bucket).budget(budget).expense(expense.getOrDefault(bucket, 0L)).build();
|
||||
}
|
||||
|
||||
private double dailyRate(Budget b) {
|
||||
long base = b.getBaseAmount() != null ? b.getBaseAmount() : 0L;
|
||||
return (double) base / baseDays(b.getBaseUnit());
|
||||
}
|
||||
|
||||
private long dayBudget(Budget b, int year, int month) {
|
||||
if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b));
|
||||
if (b.getDaily() != null) return b.getDaily();
|
||||
if (b.getMonthly() != null) return Math.round((double) b.getMonthly() / YearMonth.of(year, month).lengthOfMonth());
|
||||
if (b.getWeekly() != null) return Math.round(b.getWeekly() / 7.0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private long weekBudget(Budget b, int year, int month) {
|
||||
if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b) * 7);
|
||||
if (b.getWeekly() != null) return b.getWeekly();
|
||||
if (b.getDaily() != null) return b.getDaily() * 7;
|
||||
if (b.getMonthly() != null) return Math.round((double) b.getMonthly() / YearMonth.of(year, month).lengthOfMonth() * 7);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private long yearBudget(Budget b, int year) {
|
||||
if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b) * Year.of(year).length());
|
||||
if (b.getYearly() != null) return b.getYearly();
|
||||
if (b.getMonthly() != null) return b.getMonthly() * 12;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 이 달 예산: 고정이면 일예산×해당월 일수, 비고정이면 monthly 값 */
|
||||
private long monthlyBudget(Budget b, int daysInMonth) {
|
||||
if (Boolean.TRUE.equals(b.getFixed())) {
|
||||
long baseAmount = b.getBaseAmount() != null ? b.getBaseAmount() : 0L;
|
||||
double dailyRate = (double) baseAmount / baseDays(b.getBaseUnit());
|
||||
return Math.round(dailyRate * daysInMonth);
|
||||
}
|
||||
return b.getMonthly() != null ? b.getMonthly() : 0L;
|
||||
}
|
||||
|
||||
private int baseDays(String unit) {
|
||||
if ("WEEK".equals(unit)) return 7;
|
||||
if ("MONTH".equals(unit)) return 30;
|
||||
return 1; // DAY
|
||||
}
|
||||
|
||||
private Budget toBudget(BudgetRequest req, String category) {
|
||||
if (req.isFixed()) {
|
||||
return Budget.builder()
|
||||
.category(category)
|
||||
.fixed(true)
|
||||
.baseUnit(req.getBaseUnit())
|
||||
.baseAmount(req.getBaseAmount())
|
||||
.build();
|
||||
}
|
||||
return Budget.builder()
|
||||
.category(category)
|
||||
.fixed(false)
|
||||
.daily(req.getDaily())
|
||||
.weekly(req.getWeekly())
|
||||
.monthly(req.getMonthly())
|
||||
.yearly(req.getYearly())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Budget mustFind(Long id, Long memberId) {
|
||||
Budget b = budgetMapper.findByIdAndMember(id, memberId);
|
||||
if (b == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "예산을 찾을 수 없습니다.");
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
/**
|
||||
* 카드사명 유연 매칭.
|
||||
* 알림에서 감지한 카드사와 사용자가 등록한 카드의 「카드사/이름」을, 표기 차이
|
||||
* (KB / 국민 / 국민카드 / KB카드, 농협 / NH, 비씨 / BC 등)에 관계없이 같은 카드사로 인식한다.
|
||||
*/
|
||||
public final class CardIssuerMatcher {
|
||||
|
||||
private CardIssuerMatcher() {
|
||||
}
|
||||
|
||||
/** 두 표기가 같은 카드사를 가리키면 true */
|
||||
public static boolean matches(String walletField, String issuer) {
|
||||
String a = canon(walletField);
|
||||
String b = canon(issuer);
|
||||
if (a.isEmpty() || b.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return a.equals(b) || a.contains(b) || b.contains(a);
|
||||
}
|
||||
|
||||
/** 공백·접미사(카드/은행/페이/뱅크) 제거 + 동일 카드사 별칭 통합 후 소문자 키 */
|
||||
static String canon(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
String x = s.replaceAll("\\s+", "").toLowerCase()
|
||||
.replace("카드", "").replace("은행", "").replace("페이", "").replace("뱅크", "");
|
||||
if (x.contains("kb") || x.contains("국민")) {
|
||||
return "국민";
|
||||
}
|
||||
if (x.contains("nh") || x.contains("농협")) {
|
||||
return "농협";
|
||||
}
|
||||
if (x.equals("bc") || x.contains("비씨")) {
|
||||
return "비씨";
|
||||
}
|
||||
if (x.contains("씨티") || x.contains("시티") || x.contains("citi")) {
|
||||
return "씨티";
|
||||
}
|
||||
return x;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 결제/이체 푸시 알림 텍스트 파싱.
|
||||
* - 카드: 승인 → 지출, 취소/환불 → 수입(환불)
|
||||
* - 은행: 입금 → 수입, 출금/이체 → 지출
|
||||
* 문구가 제각각이라 휴리스틱으로 종류·수입/지출·카드사(은행)·금액·가맹점·날짜를 추출한다.
|
||||
* (서버에 두어 APK 재빌드 없이 규칙 보강 가능)
|
||||
*/
|
||||
@Component
|
||||
public class CardNotificationParser {
|
||||
|
||||
// 감지할 카드사/은행 (긴 이름 우선)
|
||||
private static final List<String> ISSUERS = List.of(
|
||||
"KB국민", "국민", "삼성", "신한", "현대", "롯데", "하나", "우리", "비씨", "BC",
|
||||
"농협", "NH", "씨티", "카카오뱅크", "카카오", "토스", "수협", "광주", "전북", "제주",
|
||||
"기업", "산업", "새마을", "신협", "우체국", "케이뱅크", "K뱅크"
|
||||
);
|
||||
|
||||
private static final Pattern AMOUNT = Pattern.compile("([0-9]{1,3}(?:,[0-9]{3})+|[0-9]{4,})\\s*원");
|
||||
private static final Pattern DATE_MD = Pattern.compile("(\\d{1,2})[./](\\d{1,2})");
|
||||
|
||||
// 명백한 광고/이벤트 표현 — 거래신호(승인/입출금/취소)가 있어도 무조건 광고로 보고 제외.
|
||||
// (실제 카드 승인/은행 입출금 알림에는 거의 등장하지 않는 표현들)
|
||||
private static final Pattern AD_BLOCK = Pattern.compile(
|
||||
"이벤트|광고|쿠폰|당첨|응모|추첨|프로모션|배너|모집|혜택|초대|수신거부|마케팅|사은품|경품|세일|"
|
||||
+ "바로가기|자세히\\s*보기|클릭|구독|뉴스레터|안내문자|광고성");
|
||||
// 약한 광고 표현 — 명확한 거래신호 없이 '결제'만 있을 때만 광고로 간주.
|
||||
// (실거래 영수증/알림에도 '포인트 적립' 등이 붙는 경우가 있어 무조건 차단하지 않음)
|
||||
private static final Pattern AD_WEAK = Pattern.compile(
|
||||
"적립|포인트|캐시백|마일리지|리워드|무료|증정|할인");
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class Parsed {
|
||||
private boolean recognized; // 가계부에 넣을 알림으로 인식되었는가
|
||||
private boolean income; // true=수입(입금/환불), false=지출(승인/출금/이체)
|
||||
private boolean bankTransfer; // true=은행 입출금/이체, false=카드 결제
|
||||
private String issuer; // 감지된 카드사/은행 (없으면 null)
|
||||
private long amount; // 금액(원)
|
||||
private String merchant; // 카드:가맹점 / 은행:적요(추정)
|
||||
private LocalDate date; // 거래일(추정, 없으면 오늘)
|
||||
private String notifKey; // 중복방지 키
|
||||
}
|
||||
|
||||
/** 알림 파싱. recognized=false 면 가계부 대상 아님(무시). */
|
||||
public Parsed parse(String title, String text, LocalDate today) {
|
||||
String raw = ((title == null ? "" : title) + "\n" + (text == null ? "" : text)).trim();
|
||||
if (raw.isBlank()) return notRecognized();
|
||||
|
||||
boolean deposit = raw.contains("입금");
|
||||
boolean withdraw = raw.contains("출금") || raw.contains("이체") || raw.contains("송금");
|
||||
boolean refund = raw.contains("취소") || raw.contains("환불");
|
||||
boolean approval = raw.contains("승인") || raw.contains("일시불") || raw.contains("할부");
|
||||
// '결제'는 광고 문구("결제하고 적립")에도 흔해 약신호로만 취급(단독으론 광고 의심)
|
||||
boolean weakApproval = raw.contains("결제");
|
||||
|
||||
Matcher am = AMOUNT.matcher(raw);
|
||||
boolean realSignal = approval || deposit || withdraw || refund; // 명확한 거래신호
|
||||
boolean anySignal = realSignal || weakApproval;
|
||||
if (!am.find() || !anySignal) {
|
||||
return notRecognized();
|
||||
}
|
||||
// 1) 명백한 광고/이벤트 표현이면 거래신호가 있어도 무시(광고 푸시·문자 오등록 방지)
|
||||
if (AD_BLOCK.matcher(raw).find()) {
|
||||
return notRecognized();
|
||||
}
|
||||
// 2) 약한 광고 표현(적립/포인트/할인 등)은 명확한 거래신호 없이 '결제'만 있으면 광고로 간주
|
||||
if (!realSignal && AD_WEAK.matcher(raw).find()) {
|
||||
return notRecognized();
|
||||
}
|
||||
long amount = Long.parseLong(am.group(1).replace(",", ""));
|
||||
|
||||
// 수입/지출: 입금·취소·환불 = 수입, 그 외(승인/결제/출금/이체) = 지출
|
||||
boolean income = deposit || refund;
|
||||
// 은행 이체성: 카드 승인 신호 없이 입금/출금/이체 → 은행 거래(카드사 대신 은행 계좌에 매칭)
|
||||
boolean bankTransfer = (deposit || withdraw) && !approval;
|
||||
|
||||
String issuer = detectIssuer(raw);
|
||||
String merchant = guessMerchant(raw);
|
||||
LocalDate date = guessDate(raw, today);
|
||||
|
||||
String notifKey = (issuer == null ? "?" : issuer) + "|" + amount + "|"
|
||||
+ (merchant == null ? "" : merchant) + "|" + date + "|"
|
||||
+ (income ? "I" : "E") + (bankTransfer ? "B" : "C");
|
||||
|
||||
return Parsed.builder()
|
||||
.recognized(true)
|
||||
.income(income)
|
||||
.bankTransfer(bankTransfer)
|
||||
.issuer(issuer)
|
||||
.amount(amount)
|
||||
.merchant(merchant)
|
||||
.date(date)
|
||||
.notifKey(notifKey)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Parsed notRecognized() {
|
||||
return Parsed.builder().recognized(false).build();
|
||||
}
|
||||
|
||||
private String detectIssuer(String raw) {
|
||||
for (String c : ISSUERS) {
|
||||
if (raw.contains(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LocalDate guessDate(String raw, LocalDate today) {
|
||||
Matcher m = DATE_MD.matcher(raw);
|
||||
while (m.find()) {
|
||||
int mo = Integer.parseInt(m.group(1));
|
||||
int d = Integer.parseInt(m.group(2));
|
||||
if (mo >= 1 && mo <= 12 && d >= 1 && d <= 31) {
|
||||
try {
|
||||
LocalDate cand = LocalDate.of(today.getYear(), mo, d);
|
||||
// 미래 날짜(연말연초 경계)면 작년으로
|
||||
return cand.isAfter(today.plusDays(1)) ? cand.minusYears(1) : cand;
|
||||
} catch (Exception ignore) {
|
||||
// 다음 후보
|
||||
}
|
||||
}
|
||||
}
|
||||
return today;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가맹점/적요 추정: 잡음(카드사·문구·금액·날짜·시간·마스킹 이름 등)을 제거한 뒤
|
||||
* 남은 한글/영문 토큰 중 가장 그럴듯한 것을 고른다. (완벽하지 않음 — 사용자가 확정 시 수정)
|
||||
*/
|
||||
private String guessMerchant(String raw) {
|
||||
String s = raw
|
||||
.replaceAll("\\[?\\s*Web발신\\s*]?", " ")
|
||||
.replaceAll("[0-9]{1,3}(?:,[0-9]{3})+\\s*원|[0-9]{4,}\\s*원", " ") // 금액
|
||||
.replaceAll("\\d{1,2}[:시]\\d{2}분?", " ") // 시간
|
||||
.replaceAll("\\d{1,2}[./]\\d{1,2}", " ") // 날짜
|
||||
.replaceAll("\\([^)]*\\)", " ") // 괄호(카드 끝번호 등)
|
||||
.replaceAll("승인취소|취소승인|승인|결제|취소|환불|일시불|할부|누적|잔액|출금|입금|이체|송금|체크|신용", " ")
|
||||
.replaceAll("[가-힣]\\*+[가-힣]?", " "); // 마스킹 이름(홍*동)
|
||||
for (String c : ISSUERS) {
|
||||
s = s.replace(c, " ");
|
||||
}
|
||||
s = s.replace("카드", " ").replace("은행", " ");
|
||||
String[] tokens = s.trim().split("\\s+");
|
||||
String best = null;
|
||||
for (String t : tokens) {
|
||||
String tok = t.replaceAll("[^0-9A-Za-z가-힣]", "").trim();
|
||||
if (tok.length() < 2 || tok.length() > 20) continue;
|
||||
if (tok.matches("\\d+")) continue;
|
||||
if (!tok.matches(".*[가-힣A-Za-z].*")) continue;
|
||||
// 더 긴(구체적인) 토큰을 가맹점으로 선호
|
||||
if (best == null || tok.length() > best.length()) best = tok;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.AccountCategory;
|
||||
import com.sb.web.account.domain.DefaultCategory;
|
||||
import com.sb.web.account.dto.CategoryRequest;
|
||||
import com.sb.web.account.dto.CategoryResponse;
|
||||
import com.sb.web.account.mapper.AccountEntryMapper;
|
||||
import com.sb.web.account.mapper.BudgetMapper;
|
||||
import com.sb.web.account.mapper.CategoryMapper;
|
||||
import com.sb.web.account.mapper.DefaultCategoryMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
/**
|
||||
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기본분류 불러오기.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryService {
|
||||
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final AccountEntryMapper entryMapper;
|
||||
private final BudgetMapper budgetMapper;
|
||||
private final DefaultCategoryMapper defaultCategoryMapper;
|
||||
|
||||
public List<CategoryResponse> list(Long memberId) {
|
||||
return categoryMapper.findByMember(memberId).stream().map(CategoryResponse::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CategoryResponse create(CategoryRequest req, Long memberId) {
|
||||
String name = req.getName().trim();
|
||||
if (categoryMapper.countByName(memberId, req.getType(), name, null) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||
}
|
||||
validateParent(req.getParentId(), req.getType(), memberId);
|
||||
Integer max = categoryMapper.maxSortOrder(memberId, req.getType());
|
||||
int order = (max == null ? 0 : max + 1);
|
||||
AccountCategory c = AccountCategory.builder()
|
||||
.memberId(memberId).type(req.getType()).name(name)
|
||||
.parentId(req.getParentId()).sortOrder(order).build();
|
||||
categoryMapper.insert(c);
|
||||
return CategoryResponse.from(c);
|
||||
}
|
||||
|
||||
/** 대분류 지정 검증: null=대분류 / 값=대분류여야(2단계 제한·동일타입·본인) */
|
||||
private void validateParent(Long parentId, String type, Long memberId) {
|
||||
if (parentId == null) {
|
||||
return;
|
||||
}
|
||||
AccountCategory parent = categoryMapper.findByIdAndMember(parentId, memberId);
|
||||
if (parent == null || !parent.getType().equals(type)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류가 올바르지 않습니다.");
|
||||
}
|
||||
if (parent.getParentId() != null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류 아래에만 소분류를 둘 수 있습니다(2단계).");
|
||||
}
|
||||
}
|
||||
|
||||
/** 드래그앤드랍 순서 변경: 전달된 id 순서대로 sort_order 재설정 (본인·동일 타입만) */
|
||||
@Transactional
|
||||
public List<CategoryResponse> reorder(Long memberId, String type, List<Long> ids) {
|
||||
int i = 0;
|
||||
for (Long id : ids) {
|
||||
AccountCategory c = categoryMapper.findByIdAndMember(id, memberId);
|
||||
if (c != null && c.getType().equals(type)) {
|
||||
categoryMapper.updateSortOrder(id, memberId, i++);
|
||||
}
|
||||
}
|
||||
return list(memberId);
|
||||
}
|
||||
|
||||
/** 이름 변경 + 대분류 이동. 이름 변경 시 기존 내역·예산의 분류명도 함께 갱신.
|
||||
* 주의: parentId 는 항상 현재값(또는 새 대분류)을 보내야 한다(미전송 시 대분류로 풀림). */
|
||||
@Transactional
|
||||
public CategoryResponse update(Long id, CategoryRequest req, Long memberId) {
|
||||
AccountCategory c = mustFind(id, memberId);
|
||||
String newName = req.getName().trim();
|
||||
if (categoryMapper.countByName(memberId, c.getType(), newName, id) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||
}
|
||||
Long newParent = req.getParentId();
|
||||
if (newParent != null) {
|
||||
if (newParent.equals(id)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "자기 자신을 대분류로 지정할 수 없습니다.");
|
||||
}
|
||||
// 소분류를 가진 대분류는 소분류로 바꿀 수 없음(3단계 방지)
|
||||
if (categoryMapper.countChildren(memberId, id) > 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 소분류로 바꿀 수 없습니다.");
|
||||
}
|
||||
}
|
||||
validateParent(newParent, c.getType(), memberId);
|
||||
String oldName = c.getName();
|
||||
c.setName(newName);
|
||||
c.setParentId(newParent);
|
||||
categoryMapper.update(c);
|
||||
if (!oldName.equals(newName)) {
|
||||
entryMapper.renameCategory(memberId, c.getType(), oldName, newName);
|
||||
if ("EXPENSE".equals(c.getType())) {
|
||||
budgetMapper.renameCategory(memberId, oldName, newName);
|
||||
}
|
||||
}
|
||||
return CategoryResponse.from(c);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
if (categoryMapper.countChildren(memberId, id) > 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 삭제할 수 없습니다. 소분류를 먼저 옮기거나 삭제하세요.");
|
||||
}
|
||||
categoryMapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
/** 관리자가 정의한 기본(디폴트) 분류를 내 분류로 복사 (대/소분류 계층 유지, 같은 이름은 건너뜀) */
|
||||
@Transactional
|
||||
public List<CategoryResponse> importDefaults(Long memberId) {
|
||||
List<DefaultCategory> defaults = defaultCategoryMapper.findAll();
|
||||
// 1) 대분류 먼저 생성/확보 → default.id → 내 분류.id 매핑
|
||||
Map<Long, Long> majorMap = new HashMap<>();
|
||||
for (DefaultCategory d : defaults) {
|
||||
if (d.getParentId() != null) {
|
||||
continue;
|
||||
}
|
||||
majorMap.put(d.getId(), ensureCategory(memberId, d.getType(), d.getName(), null));
|
||||
}
|
||||
// 2) 소분류 생성 (매핑된 대분류 아래; 부모 매핑이 없으면 대분류로 취급)
|
||||
for (DefaultCategory d : defaults) {
|
||||
if (d.getParentId() == null) {
|
||||
continue;
|
||||
}
|
||||
ensureCategory(memberId, d.getType(), d.getName(), majorMap.get(d.getParentId()));
|
||||
}
|
||||
return list(memberId);
|
||||
}
|
||||
|
||||
/** 같은 이름 분류가 있으면 그 id, 없으면 생성해서 id 반환 */
|
||||
private Long ensureCategory(Long memberId, String type, String name, Long parentId) {
|
||||
AccountCategory existing = categoryMapper.findByMemberTypeName(memberId, type, name);
|
||||
if (existing != null) {
|
||||
return existing.getId();
|
||||
}
|
||||
Integer max = categoryMapper.maxSortOrder(memberId, type);
|
||||
AccountCategory c = AccountCategory.builder()
|
||||
.memberId(memberId).type(type).name(name).parentId(parentId)
|
||||
.sortOrder(max == null ? 0 : max + 1).build();
|
||||
categoryMapper.insert(c);
|
||||
return c.getId();
|
||||
}
|
||||
|
||||
private AccountCategory mustFind(Long id, Long memberId) {
|
||||
AccountCategory c = categoryMapper.findByIdAndMember(id, memberId);
|
||||
if (c == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "분류를 찾을 수 없습니다.");
|
||||
}
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.DefaultCategory;
|
||||
import com.sb.web.account.mapper.DefaultCategoryMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 기본(디폴트) 분류 초기 시드. default_category 가 비어 있을 때만 한 번 생성한다.
|
||||
* (관리자가 이후 자유롭게 수정/삭제 가능 — 비어 있을 때만 채우므로 운영 데이터를 덮어쓰지 않음)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultCategorySeeder implements ApplicationRunner {
|
||||
|
||||
private final DefaultCategoryMapper mapper;
|
||||
|
||||
// 대분류 → 소분류 목록 (입력 순서 유지)
|
||||
private static final Map<String, List<String>> EXPENSE = new LinkedHashMap<>();
|
||||
private static final Map<String, List<String>> INCOME = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
EXPENSE.put("식비", List.of("외식", "식료품", "카페/간식", "배달"));
|
||||
EXPENSE.put("교통", List.of("대중교통", "택시", "주유", "주차/통행료"));
|
||||
EXPENSE.put("주거/통신", List.of("월세/관리비", "공과금", "통신비", "인터넷"));
|
||||
EXPENSE.put("생활", List.of("생필품", "의류/미용", "가구/가전"));
|
||||
EXPENSE.put("건강/의료", List.of("병원", "약국", "운동"));
|
||||
EXPENSE.put("문화/여가", List.of("영화/공연", "여행", "취미", "구독서비스"));
|
||||
EXPENSE.put("교육", List.of("학원", "도서", "강의"));
|
||||
EXPENSE.put("경조사", List.of("축의금", "부의금", "선물"));
|
||||
EXPENSE.put("금융", List.of("보험", "대출이자", "수수료"));
|
||||
EXPENSE.put("기타", List.of());
|
||||
|
||||
INCOME.put("급여", List.of("월급", "상여금"));
|
||||
INCOME.put("부수입", List.of("용돈", "이자/배당", "환급"));
|
||||
INCOME.put("기타수입", List.of("중고판매", "기타"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
if (!mapper.findAll().isEmpty()) {
|
||||
return; // 이미 데이터가 있으면 시드하지 않음
|
||||
}
|
||||
int n = seed("EXPENSE", EXPENSE) + seed("INCOME", INCOME);
|
||||
log.info("[default-category] 기본 분류 시드 생성: {}건", n);
|
||||
}
|
||||
|
||||
private int seed(String type, Map<String, List<String>> tree) {
|
||||
int count = 0;
|
||||
int majorOrder = 0;
|
||||
for (Map.Entry<String, List<String>> e : tree.entrySet()) {
|
||||
DefaultCategory major = DefaultCategory.builder()
|
||||
.type(type).name(e.getKey()).parentId(null).sortOrder(majorOrder++).build();
|
||||
mapper.insert(major); // 생성키(id) 채워짐
|
||||
count++;
|
||||
int subOrder = 0;
|
||||
for (String child : e.getValue()) {
|
||||
mapper.insert(DefaultCategory.builder()
|
||||
.type(type).name(child).parentId(major.getId()).sortOrder(subOrder++).build());
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.DefaultCategory;
|
||||
import com.sb.web.account.dto.CategoryReorderRequest;
|
||||
import com.sb.web.account.dto.CategoryRequest;
|
||||
import com.sb.web.account.dto.CategoryResponse;
|
||||
import com.sb.web.account.mapper.DefaultCategoryMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 기본(디폴트) 분류 서비스 — 전체 공용 템플릿(관리자 전용 CRUD). 2단계(대/소분류) 제한.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultCategoryService {
|
||||
|
||||
private final DefaultCategoryMapper mapper;
|
||||
|
||||
public List<CategoryResponse> list() {
|
||||
return mapper.findAll().stream().map(this::toResponse).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CategoryResponse create(CategoryRequest req) {
|
||||
String name = req.getName().trim();
|
||||
if (mapper.countByName(req.getType(), name, null) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||
}
|
||||
validateParent(req.getParentId(), req.getType());
|
||||
Integer max = mapper.maxSortOrder(req.getType());
|
||||
DefaultCategory c = DefaultCategory.builder()
|
||||
.type(req.getType()).name(name).parentId(req.getParentId())
|
||||
.sortOrder(max == null ? 0 : max + 1).build();
|
||||
mapper.insert(c);
|
||||
return toResponse(c);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CategoryResponse update(Long id, CategoryRequest req) {
|
||||
DefaultCategory c = mustFind(id);
|
||||
String newName = req.getName().trim();
|
||||
if (mapper.countByName(c.getType(), newName, id) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||
}
|
||||
Long newParent = req.getParentId();
|
||||
if (newParent != null) {
|
||||
if (newParent.equals(id)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "자기 자신을 대분류로 지정할 수 없습니다.");
|
||||
}
|
||||
if (mapper.countChildren(id) > 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 소분류로 바꿀 수 없습니다.");
|
||||
}
|
||||
}
|
||||
validateParent(newParent, c.getType());
|
||||
c.setName(newName);
|
||||
c.setParentId(newParent);
|
||||
mapper.update(c);
|
||||
return toResponse(c);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
mustFind(id);
|
||||
if (mapper.countChildren(id) > 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 삭제할 수 없습니다. 소분류를 먼저 옮기거나 삭제하세요.");
|
||||
}
|
||||
mapper.delete(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<CategoryResponse> reorder(CategoryReorderRequest req) {
|
||||
int i = 0;
|
||||
for (Long id : req.getIds()) {
|
||||
DefaultCategory c = mapper.findById(id);
|
||||
if (c != null && c.getType().equals(req.getType())) {
|
||||
mapper.updateSortOrder(id, i++);
|
||||
}
|
||||
}
|
||||
return list();
|
||||
}
|
||||
|
||||
private void validateParent(Long parentId, String type) {
|
||||
if (parentId == null) {
|
||||
return;
|
||||
}
|
||||
DefaultCategory parent = mapper.findById(parentId);
|
||||
if (parent == null || !parent.getType().equals(type)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류가 올바르지 않습니다.");
|
||||
}
|
||||
if (parent.getParentId() != null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류 아래에만 소분류를 둘 수 있습니다(2단계).");
|
||||
}
|
||||
}
|
||||
|
||||
private DefaultCategory mustFind(Long id) {
|
||||
DefaultCategory c = mapper.findById(id);
|
||||
if (c == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "기본 분류를 찾을 수 없습니다.");
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private CategoryResponse toResponse(DefaultCategory c) {
|
||||
return CategoryResponse.builder()
|
||||
.id(c.getId()).type(c.getType()).name(c.getName()).parentId(c.getParentId())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 환율 조회. open.er-api.com(무료·무인증) 사용. 통화(base)별로 하루 1회만 호출하고 캐시.
|
||||
* 실패해도 예외를 던지지 않고 null(또는 직전 캐시) 반환.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FxService {
|
||||
|
||||
private static final String URL = "https://open.er-api.com/v6/latest/{base}";
|
||||
private final RestClient restClient = RestClient.builder().build();
|
||||
|
||||
private record Cached(LocalDate date, Instant fetchedAt, JsonNode rates) {}
|
||||
private final Map<String, Cached> cache = new ConcurrentHashMap<>();
|
||||
|
||||
/** from 통화 1단위 → to 통화 환율. 조회 실패 시 null. */
|
||||
/** 해당 통화 환율이 마지막으로 캐시된 시각. 캐시 없으면 null. */
|
||||
public Instant getCachedAt(String from) {
|
||||
if (from == null) return null;
|
||||
Cached c = cache.get(from.trim().toUpperCase());
|
||||
return c != null ? c.fetchedAt() : null;
|
||||
}
|
||||
|
||||
/** from 통화 1단위 → to 통화 환율. 조회 실패 시 null. */
|
||||
public BigDecimal getRate(String from, String to) {
|
||||
if (from == null || to == null) return null;
|
||||
String base = from.trim().toUpperCase();
|
||||
String target = to.trim().toUpperCase();
|
||||
if (base.isEmpty() || target.isEmpty()) return null;
|
||||
if (base.equals(target)) return BigDecimal.ONE;
|
||||
JsonNode rates = ratesFor(base);
|
||||
if (rates == null) return null;
|
||||
JsonNode r = rates.get(target);
|
||||
return (r != null && r.isNumber()) ? r.decimalValue() : null;
|
||||
}
|
||||
|
||||
private JsonNode ratesFor(String base) {
|
||||
Cached c = cache.get(base);
|
||||
LocalDate today = LocalDate.now();
|
||||
if (c != null && c.date().equals(today)) return c.rates();
|
||||
try {
|
||||
JsonNode root = restClient.get()
|
||||
.uri(URL, base)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
if (root == null || !"success".equals(root.path("result").asText())) {
|
||||
return c != null ? c.rates() : null;
|
||||
}
|
||||
JsonNode rates = root.path("rates");
|
||||
if (rates.isMissingNode() || !rates.isObject()) {
|
||||
return c != null ? c.rates() : null;
|
||||
}
|
||||
cache.put(base, new Cached(today, Instant.now(), rates));
|
||||
return rates;
|
||||
} catch (Exception e) {
|
||||
log.warn("환율 조회 실패 base={}: {}", base, e.toString());
|
||||
return c != null ? c.rates() : null; // 실패 시 직전 캐시라도
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.InvestHolding;
|
||||
import com.sb.web.account.domain.InvestTrade;
|
||||
import com.sb.web.account.domain.Wallet;
|
||||
import com.sb.web.account.dto.HoldingRequest;
|
||||
import com.sb.web.account.dto.HoldingResponse;
|
||||
import com.sb.web.account.dto.TradeRequest;
|
||||
import com.sb.web.account.dto.TradeResponse;
|
||||
import com.sb.web.account.mapper.InvestMapper;
|
||||
import com.sb.web.account.mapper.WalletMapper;
|
||||
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.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 투자 포트폴리오(C). 보유종목·매매이력 관리와 평단/실현·평가손익 산출.
|
||||
* 모든 데이터는 memberId(소유자)로 격리한다.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InvestService {
|
||||
|
||||
private final InvestMapper investMapper;
|
||||
private final WalletMapper walletMapper;
|
||||
private final StockQuoteService stockQuoteService;
|
||||
|
||||
/* ===================== 보유 종목 ===================== */
|
||||
|
||||
public List<HoldingResponse> listHoldings(Long memberId, Long walletId) {
|
||||
requireInvestWallet(walletId, memberId);
|
||||
Map<Long, List<InvestTrade>> tradesByHolding = tradesByHolding(memberId);
|
||||
return investMapper.findHoldingsByWallet(memberId, walletId).stream()
|
||||
.map(h -> toResponse(h, tradesByHolding.getOrDefault(h.getId(), List.of())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public HoldingResponse createHolding(HoldingRequest req, Long memberId) {
|
||||
requireInvestWallet(req.getWalletId(), memberId);
|
||||
InvestHolding h = InvestHolding.builder()
|
||||
.memberId(memberId)
|
||||
.walletId(req.getWalletId())
|
||||
.name(req.getName().trim())
|
||||
.ticker(blankToNull(req.getTicker()))
|
||||
.currentPrice(req.getCurrentPrice())
|
||||
.build();
|
||||
investMapper.insertHolding(h);
|
||||
return toResponse(h, List.of());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public HoldingResponse updateHolding(Long id, HoldingRequest req, Long memberId) {
|
||||
InvestHolding h = mustHolding(id, memberId);
|
||||
h.setName(req.getName().trim());
|
||||
h.setTicker(blankToNull(req.getTicker()));
|
||||
h.setCurrentPrice(req.getCurrentPrice());
|
||||
investMapper.updateHolding(h);
|
||||
return toResponse(h, investMapper.findTradesByHolding(id, memberId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteHolding(Long id, Long memberId) {
|
||||
mustHolding(id, memberId);
|
||||
investMapper.deleteTradesByHolding(id, memberId);
|
||||
investMapper.deleteHolding(id, memberId);
|
||||
}
|
||||
|
||||
/** 종목코드가 있는 보유종목의 현재가를 시세 조회로 갱신. 조회 성공한 종목만 반영 */
|
||||
@Transactional
|
||||
public List<HoldingResponse> refreshPrices(Long memberId, Long walletId) {
|
||||
requireInvestWallet(walletId, memberId);
|
||||
Map<String, Long> cache = new HashMap<>();
|
||||
for (InvestHolding h : investMapper.findHoldingsByWallet(memberId, walletId)) {
|
||||
applyQuote(h, cache);
|
||||
}
|
||||
return listHoldings(memberId, walletId);
|
||||
}
|
||||
|
||||
/** 회원의 모든 투자계좌 보유종목 현재가를 일괄 갱신(계좌 목록 평가액 반영용) */
|
||||
@Transactional
|
||||
public void refreshAllPrices(Long memberId) {
|
||||
Map<String, Long> cache = new HashMap<>();
|
||||
for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) {
|
||||
applyQuote(h, cache);
|
||||
}
|
||||
}
|
||||
|
||||
/** 종목코드로 시세를 받아 현재가 갱신(동일 종목코드는 호출당 1회만 조회) */
|
||||
private void applyQuote(InvestHolding h, Map<String, Long> cache) {
|
||||
String ticker = h.getTicker();
|
||||
if (ticker == null || ticker.isBlank()) return;
|
||||
Long price = cache.computeIfAbsent(ticker.trim(), stockQuoteService::getDomesticPrice);
|
||||
if (price != null && price > 0) {
|
||||
h.setCurrentPrice(price);
|
||||
investMapper.updateHolding(h);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 매매 ===================== */
|
||||
|
||||
public List<TradeResponse> listTrades(Long holdingId, Long memberId) {
|
||||
mustHolding(holdingId, memberId);
|
||||
return investMapper.findTradesByHolding(holdingId, memberId).stream()
|
||||
.map(TradeResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TradeResponse addTrade(Long holdingId, TradeRequest req, Long memberId) {
|
||||
mustHolding(holdingId, memberId);
|
||||
String type = "SELL".equals(req.getTradeType()) ? "SELL" : "BUY";
|
||||
if ("SELL".equals(type)) {
|
||||
Calc c = calc(investMapper.findTradesByHolding(holdingId, memberId));
|
||||
if (req.getQuantity().compareTo(c.quantity()) > 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST,
|
||||
"매도 수량이 보유수량(" + c.quantity().stripTrailingZeros().toPlainString() + ")을 초과합니다.");
|
||||
}
|
||||
}
|
||||
InvestTrade t = InvestTrade.builder()
|
||||
.memberId(memberId)
|
||||
.holdingId(holdingId)
|
||||
.tradeType(type)
|
||||
.tradeDate(req.getTradeDate())
|
||||
.quantity(req.getQuantity())
|
||||
.price(req.getPrice())
|
||||
.fee(req.getFee() != null ? req.getFee() : 0L)
|
||||
.build();
|
||||
investMapper.insertTrade(t);
|
||||
return TradeResponse.from(t);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TradeResponse updateTrade(Long id, TradeRequest req, Long memberId) {
|
||||
InvestTrade existing = investMapper.findTradeByIdAndMember(id, memberId);
|
||||
if (existing == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "매매 내역을 찾을 수 없습니다.");
|
||||
}
|
||||
Long holdingId = existing.getHoldingId();
|
||||
String type = "SELL".equals(req.getTradeType()) ? "SELL" : "BUY";
|
||||
long fee = req.getFee() != null ? req.getFee() : 0L;
|
||||
|
||||
// 편집을 반영한 시점별 시뮬레이션 — 어느 시점에도 보유수량이 음수가 되면 거부
|
||||
java.util.List<InvestTrade> sim = new java.util.ArrayList<>();
|
||||
for (InvestTrade t : investMapper.findTradesByHolding(holdingId, memberId)) {
|
||||
if (t.getId().equals(id)) {
|
||||
sim.add(InvestTrade.builder().id(id).tradeType(type).tradeDate(req.getTradeDate())
|
||||
.quantity(req.getQuantity()).price(req.getPrice()).fee(fee).build());
|
||||
} else {
|
||||
sim.add(t);
|
||||
}
|
||||
}
|
||||
sim.sort(java.util.Comparator.comparing(InvestTrade::getTradeDate)
|
||||
.thenComparing(InvestTrade::getId));
|
||||
BigDecimal running = BigDecimal.ZERO;
|
||||
for (InvestTrade t : sim) {
|
||||
if ("BUY".equals(t.getTradeType())) {
|
||||
running = running.add(t.getQuantity());
|
||||
} else {
|
||||
running = running.subtract(t.getQuantity());
|
||||
if (running.signum() < 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST,
|
||||
"수정하면 보유수량이 음수가 됩니다. 매도 수량/순서를 확인하세요.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existing.setTradeType(type);
|
||||
existing.setTradeDate(req.getTradeDate());
|
||||
existing.setQuantity(req.getQuantity());
|
||||
existing.setPrice(req.getPrice());
|
||||
existing.setFee(fee);
|
||||
investMapper.updateTrade(existing);
|
||||
return TradeResponse.from(existing);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTrade(Long id, Long memberId) {
|
||||
if (investMapper.findTradeByIdAndMember(id, memberId) == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "매매 내역을 찾을 수 없습니다.");
|
||||
}
|
||||
investMapper.deleteTrade(id, memberId);
|
||||
}
|
||||
|
||||
/* ===================== 지갑 단위 평가 (AccountService 연동) ===================== */
|
||||
|
||||
/** 투자(INVEST) 지갑별 {예수금 증감(cashDelta), 주식 평가금액(stockEval)} */
|
||||
public Map<Long, WalletInvest> valuationByWallet(Long memberId) {
|
||||
Map<Long, List<InvestTrade>> tradesByHolding = tradesByHolding(memberId);
|
||||
Map<Long, WalletInvest> result = new HashMap<>();
|
||||
for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) {
|
||||
List<InvestTrade> trades = tradesByHolding.getOrDefault(h.getId(), List.of());
|
||||
Calc c = calc(trades);
|
||||
long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : c.avgPrice();
|
||||
WalletInvest wi = result.computeIfAbsent(h.getWalletId(), k -> new WalletInvest());
|
||||
wi.cashDelta += cashDelta(trades);
|
||||
wi.stockEval += amountOf(c.quantity(), evalPrice);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 투자 지갑별 가치 holder */
|
||||
public static class WalletInvest {
|
||||
public long cashDelta; // 매매로 인한 예수금 증감 (Σ매도대금 − Σ매수금액)
|
||||
public long stockEval; // 주식 평가금액 (Σ수량×평가단가)
|
||||
}
|
||||
|
||||
/* ===================== 내부 ===================== */
|
||||
|
||||
private Map<Long, List<InvestTrade>> tradesByHolding(Long memberId) {
|
||||
Map<Long, List<InvestTrade>> map = new HashMap<>();
|
||||
for (InvestTrade t : investMapper.findTradesByMember(memberId)) {
|
||||
map.computeIfAbsent(t.getHoldingId(), k -> new java.util.ArrayList<>()).add(t);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 매매이력(시간순)으로 보유수량·원가·실현손익 산출. 수량은 소수점, 금액은 원 단위 정수. */
|
||||
private Calc calc(List<InvestTrade> trades) {
|
||||
BigDecimal qty = BigDecimal.ZERO;
|
||||
long costBasis = 0, realized = 0;
|
||||
for (InvestTrade t : trades) {
|
||||
long amount = amountOf(t.getQuantity(), t.getPrice());
|
||||
long fee = t.getFee() != null ? t.getFee() : 0L;
|
||||
if ("BUY".equals(t.getTradeType())) {
|
||||
qty = qty.add(t.getQuantity());
|
||||
costBasis += amount + fee;
|
||||
} else { // SELL
|
||||
// 부분 매도 시 보유원가를 수량 비율로 제거, 전량 매도 시 잔여 원가 전부 제거(반올림 잔차 방지)
|
||||
boolean partial = qty.signum() > 0 && t.getQuantity().compareTo(qty) < 0;
|
||||
long costRemoved = partial
|
||||
? Math.round(costBasis * t.getQuantity().doubleValue() / qty.doubleValue())
|
||||
: costBasis;
|
||||
long proceeds = amount - fee;
|
||||
realized += proceeds - costRemoved;
|
||||
costBasis -= costRemoved;
|
||||
qty = qty.subtract(t.getQuantity());
|
||||
if (qty.signum() <= 0) {
|
||||
qty = BigDecimal.ZERO;
|
||||
costBasis = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Calc(qty, costBasis, realized);
|
||||
}
|
||||
|
||||
private record Calc(BigDecimal quantity, long costBasis, long realized) {
|
||||
long avgPrice() {
|
||||
return quantity.signum() > 0 ? Math.round(costBasis / quantity.doubleValue()) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 거래금액(원) = 수량 × 단가, 원 단위 반올림 */
|
||||
private static long amountOf(BigDecimal quantity, long price) {
|
||||
return quantity.multiply(BigDecimal.valueOf(price))
|
||||
.setScale(0, RoundingMode.HALF_UP)
|
||||
.longValue();
|
||||
}
|
||||
|
||||
/** 매매로 인한 예수금 증감 = −Σ매수(금액+수수료) + Σ매도(금액−수수료) */
|
||||
private long cashDelta(List<InvestTrade> trades) {
|
||||
long cashDelta = 0;
|
||||
for (InvestTrade t : trades) {
|
||||
long amount = amountOf(t.getQuantity(), t.getPrice());
|
||||
long fee = t.getFee() != null ? t.getFee() : 0L;
|
||||
cashDelta += "BUY".equals(t.getTradeType()) ? -(amount + fee) : (amount - fee);
|
||||
}
|
||||
return cashDelta;
|
||||
}
|
||||
|
||||
private HoldingResponse toResponse(InvestHolding h, List<InvestTrade> trades) {
|
||||
Calc c = calc(trades);
|
||||
long avgPrice = c.avgPrice();
|
||||
long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : avgPrice;
|
||||
long evalValue = amountOf(c.quantity(), evalPrice);
|
||||
long evalGain = evalValue - c.costBasis();
|
||||
Double returnPct = c.costBasis() > 0 ? Math.round(evalGain * 1000.0 / c.costBasis()) / 10.0 : null;
|
||||
return HoldingResponse.builder()
|
||||
.id(h.getId())
|
||||
.walletId(h.getWalletId())
|
||||
.name(h.getName())
|
||||
.ticker(h.getTicker())
|
||||
.currentPrice(h.getCurrentPrice())
|
||||
.quantity(c.quantity())
|
||||
.avgPrice(avgPrice)
|
||||
.costBasis(c.costBasis())
|
||||
.evalValue(evalValue)
|
||||
.evalGain(evalGain)
|
||||
.returnPct(returnPct)
|
||||
.realizedPL(c.realized)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void requireInvestWallet(Long walletId, Long memberId) {
|
||||
Wallet w = walletMapper.findByIdAndMember(walletId, memberId);
|
||||
if (w == null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "투자계좌가 올바르지 않습니다.");
|
||||
}
|
||||
if (!"INVEST".equals(w.getType())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "투자(INVEST) 계좌만 종목을 보유할 수 있습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private InvestHolding mustHolding(Long id, Long memberId) {
|
||||
InvestHolding h = investMapper.findHoldingByIdAndMember(id, memberId);
|
||||
if (h == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "종목을 찾을 수 없습니다.");
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
private String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.QuickEntry;
|
||||
import com.sb.web.account.dto.QuickEntryRequest;
|
||||
import com.sb.web.account.dto.QuickEntryResponse;
|
||||
import com.sb.web.account.mapper.QuickEntryMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 자주 쓰는 내역(빠른 등록 템플릿) 서비스 — 사용자별.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class QuickEntryService {
|
||||
|
||||
private final QuickEntryMapper mapper;
|
||||
|
||||
public List<QuickEntryResponse> list(Long memberId) {
|
||||
return mapper.findByMember(memberId).stream().map(QuickEntryResponse::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public QuickEntryResponse create(QuickEntryRequest req, Long memberId) {
|
||||
Integer max = mapper.maxSortOrder(memberId);
|
||||
QuickEntry e = QuickEntry.builder()
|
||||
.memberId(memberId)
|
||||
.label(blankToNull(req.getLabel()))
|
||||
.type(req.getType())
|
||||
.category("EXPENSE".equals(req.getType()) || "INCOME".equals(req.getType()) ? blankToNull(req.getCategory()) : null)
|
||||
.amount(req.getAmount())
|
||||
.memo(blankToNull(req.getMemo()))
|
||||
.walletId(req.getWalletId())
|
||||
.sortOrder(max == null ? 0 : max + 1)
|
||||
.build();
|
||||
mapper.insert(e);
|
||||
return QuickEntryResponse.from(mapper.findByIdAndMember(e.getId(), memberId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long memberId) {
|
||||
if (mapper.findByIdAndMember(id, memberId) == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "자주 쓰는 내역을 찾을 수 없습니다.");
|
||||
}
|
||||
mapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
private String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.AccountEntry;
|
||||
import com.sb.web.account.domain.Recurring;
|
||||
import com.sb.web.account.dto.RecurringRequest;
|
||||
import com.sb.web.account.dto.RecurringResponse;
|
||||
import com.sb.web.account.mapper.AccountEntryMapper;
|
||||
import com.sb.web.account.mapper.RecurringMapper;
|
||||
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.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 정기/반복 거래 서비스. CRUD + 밀린 회차 자동 생성(중복 없이).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringService {
|
||||
|
||||
private final RecurringMapper recurringMapper;
|
||||
private final AccountEntryMapper entryMapper;
|
||||
|
||||
public List<RecurringResponse> list(Long memberId) {
|
||||
return recurringMapper.findByMember(memberId).stream().map(this::toResponse).toList();
|
||||
}
|
||||
|
||||
// 무료 회원 고정지출 등록 한도(써보고 → 결제 유도). 유료·관리자는 무제한.
|
||||
public static final int FREE_RECURRING_LIMIT = 2;
|
||||
|
||||
@Transactional
|
||||
public RecurringResponse create(RecurringRequest req, Long memberId, boolean premium) {
|
||||
if (!premium && recurringMapper.countByMember(memberId) >= FREE_RECURRING_LIMIT) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST,
|
||||
"무료 회원은 고정 지출을 " + FREE_RECURRING_LIMIT + "개까지 등록할 수 있어요. 유료로 업그레이드하면 무제한이에요.");
|
||||
}
|
||||
Recurring r = toEntity(req);
|
||||
r.setMemberId(memberId);
|
||||
recurringMapper.insert(r);
|
||||
return toResponse(recurringMapper.findByIdAndMember(r.getId(), memberId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RecurringResponse update(Long id, RecurringRequest req, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
Recurring r = toEntity(req);
|
||||
r.setId(id);
|
||||
r.setMemberId(memberId);
|
||||
recurringMapper.update(r);
|
||||
return toResponse(recurringMapper.findByIdAndMember(id, memberId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
recurringMapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
/** 활성 규칙의 밀린 회차를 오늘까지 생성 (중복 없음). 생성 건수 반환 */
|
||||
@Transactional
|
||||
public int run(Long memberId) {
|
||||
LocalDate today = LocalDate.now();
|
||||
int count = 0;
|
||||
for (Recurring r : recurringMapper.findActiveByMember(memberId)) {
|
||||
LocalDate cursor = r.getLastRunDate() != null ? r.getLastRunDate() : r.getStartDate().minusDays(1);
|
||||
LocalDate next = nextOccurrence(r, cursor);
|
||||
LocalDate generatedUpTo = null;
|
||||
int guard = 0;
|
||||
while (next != null && !next.isAfter(today)
|
||||
&& (r.getEndDate() == null || !next.isAfter(r.getEndDate()))
|
||||
&& guard++ < 1000) {
|
||||
entryMapper.insert(buildEntry(r, next, memberId));
|
||||
generatedUpTo = next;
|
||||
count++;
|
||||
next = nextOccurrence(r, next);
|
||||
}
|
||||
if (generatedUpTo != null) {
|
||||
recurringMapper.updateLastRun(r.getId(), generatedUpTo);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/* ===================== 내부 ===================== */
|
||||
|
||||
private AccountEntry buildEntry(Recurring r, LocalDate date, Long memberId) {
|
||||
boolean transfer = "TRANSFER".equals(r.getType());
|
||||
// 메모가 없으면 제목으로 채워 '확인 필요' 목록에서 알아보기 쉽게 한다.
|
||||
String memo = (r.getMemo() != null && !r.getMemo().isBlank()) ? r.getMemo() : r.getTitle();
|
||||
return AccountEntry.builder()
|
||||
.memberId(memberId)
|
||||
.entryDate(date)
|
||||
.type(r.getType())
|
||||
.category(transfer ? null : r.getCategory())
|
||||
.amount(r.getAmount())
|
||||
.memo(memo)
|
||||
.walletId(r.getWalletId())
|
||||
.toWalletId(transfer ? r.getToWalletId() : null)
|
||||
// 실제 결제/이체가 일어났는지 사용자가 확인하도록 '확인 필요'로 생성
|
||||
.pending(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** from 이후 첫 발생일 (없으면 null) */
|
||||
private LocalDate nextOccurrence(Recurring r, LocalDate from) {
|
||||
return switch (r.getFrequency()) {
|
||||
case "DAILY" -> from.plusDays(1);
|
||||
case "WEEKLY" -> {
|
||||
int dow = r.getDayOfWeek() != null ? r.getDayOfWeek() : 1;
|
||||
LocalDate d = from.plusDays(1);
|
||||
while (d.getDayOfWeek().getValue() != dow) d = d.plusDays(1);
|
||||
yield d;
|
||||
}
|
||||
case "MONTHLY" -> {
|
||||
int dom = r.getDayOfMonth() != null ? r.getDayOfMonth() : 1;
|
||||
YearMonth ym = YearMonth.from(from);
|
||||
LocalDate c = clamp(ym, dom);
|
||||
if (!c.isAfter(from)) c = clamp(ym.plusMonths(1), dom);
|
||||
yield c;
|
||||
}
|
||||
case "YEARLY" -> {
|
||||
int moy = r.getMonthOfYear() != null ? r.getMonthOfYear() : 1;
|
||||
int dom = r.getDayOfMonth() != null ? r.getDayOfMonth() : 1;
|
||||
LocalDate c = clamp(YearMonth.of(from.getYear(), moy), dom);
|
||||
if (!c.isAfter(from)) c = clamp(YearMonth.of(from.getYear() + 1, moy), dom);
|
||||
yield c;
|
||||
}
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private LocalDate clamp(YearMonth ym, int dom) {
|
||||
return ym.atDay(Math.min(dom, ym.lengthOfMonth()));
|
||||
}
|
||||
|
||||
private Recurring toEntity(RecurringRequest req) {
|
||||
return Recurring.builder()
|
||||
.title(req.getTitle().trim())
|
||||
.type(req.getType())
|
||||
.amount(req.getAmount())
|
||||
.category("TRANSFER".equals(req.getType()) ? null : blankToNull(req.getCategory()))
|
||||
.memo(blankToNull(req.getMemo()))
|
||||
.walletId(req.getWalletId())
|
||||
.toWalletId("TRANSFER".equals(req.getType()) ? req.getToWalletId() : null)
|
||||
.frequency(req.getFrequency())
|
||||
.dayOfMonth(req.getDayOfMonth())
|
||||
.dayOfWeek(req.getDayOfWeek())
|
||||
.monthOfYear(req.getMonthOfYear())
|
||||
.startDate(req.getStartDate())
|
||||
.endDate(req.getEndDate())
|
||||
.active(req.isActive())
|
||||
.build();
|
||||
}
|
||||
|
||||
private RecurringResponse toResponse(Recurring r) {
|
||||
LocalDate cursor = r.getLastRunDate() != null ? r.getLastRunDate() : r.getStartDate().minusDays(1);
|
||||
LocalDate next = nextOccurrence(r, cursor);
|
||||
if (next != null && r.getEndDate() != null && next.isAfter(r.getEndDate())) {
|
||||
next = null;
|
||||
}
|
||||
return RecurringResponse.builder()
|
||||
.id(r.getId()).title(r.getTitle()).type(r.getType()).amount(r.getAmount())
|
||||
.category(r.getCategory()).memo(r.getMemo())
|
||||
.walletId(r.getWalletId()).walletName(r.getWalletName())
|
||||
.toWalletId(r.getToWalletId()).toWalletName(r.getToWalletName())
|
||||
.frequency(r.getFrequency()).dayOfMonth(r.getDayOfMonth())
|
||||
.dayOfWeek(r.getDayOfWeek()).monthOfYear(r.getMonthOfYear())
|
||||
.startDate(r.getStartDate()).endDate(r.getEndDate()).lastRunDate(r.getLastRunDate())
|
||||
.nextDate(next).active(Boolean.TRUE.equals(r.getActive()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private Recurring mustFind(Long id, Long memberId) {
|
||||
Recurring r = recurringMapper.findByIdAndMember(id, memberId);
|
||||
if (r == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "정기 거래를 찾을 수 없습니다.");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
private String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* 국내 상장 종목 현재가(시세) 조회. 네이버 금융 폴링 API 사용(무인증).
|
||||
* 종목코드(6자리)로 현재가를 가져온다. 실패해도 예외를 던지지 않고 null 반환.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class StockQuoteService {
|
||||
|
||||
private static final String URL =
|
||||
"https://polling.finance.naver.com/api/realtime/domestic/stock/{code}";
|
||||
|
||||
private final RestClient restClient = RestClient.builder().build();
|
||||
|
||||
/** 국내 상장 종목 현재가(원). 조회 실패/미상장/코드없음이면 null */
|
||||
public Long getDomesticPrice(String ticker) {
|
||||
if (ticker == null || ticker.isBlank()) return null;
|
||||
String code = ticker.trim();
|
||||
try {
|
||||
JsonNode root = restClient.get()
|
||||
.uri(URL, code)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
if (root == null) return null;
|
||||
JsonNode datas = root.path("datas");
|
||||
if (!datas.isArray() || datas.isEmpty()) return null;
|
||||
String priceStr = datas.get(0).path("closePrice").asText(null);
|
||||
if (priceStr == null || priceStr.isBlank()) return null;
|
||||
String digits = priceStr.replaceAll("[^0-9]", "");
|
||||
return digits.isEmpty() ? null : Long.parseLong(digits);
|
||||
} catch (Exception e) {
|
||||
log.warn("시세 조회 실패 ticker={}: {}", code, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Google Cloud Vision 영수증 OCR. 이미지(byte) → 전체 텍스트.
|
||||
* API 키는 환경변수(GOOGLE_VISION_API_KEY)로만 주입하며, 프론트엔드엔 노출하지 않는다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class VisionOcrService {
|
||||
|
||||
private static final String ENDPOINT = "https://vision.googleapis.com/v1/images:annotate";
|
||||
|
||||
private final RestClient restClient = RestClient.builder().build();
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String apiKey;
|
||||
|
||||
public VisionOcrService(ObjectMapper objectMapper,
|
||||
@Value("${google.vision.api-key:}") String apiKey) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return apiKey != null && !apiKey.isBlank();
|
||||
}
|
||||
|
||||
/** 영수증 이미지에서 전체 텍스트 추출 */
|
||||
public String recognize(byte[] image) {
|
||||
if (!isEnabled()) {
|
||||
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "영수증 OCR이 설정되지 않았습니다.");
|
||||
}
|
||||
String content = Base64.getEncoder().encodeToString(image);
|
||||
Map<String, Object> body = Map.of(
|
||||
"requests", List.of(Map.of(
|
||||
"image", Map.of("content", content),
|
||||
"features", List.of(Map.of("type", "DOCUMENT_TEXT_DETECTION")),
|
||||
"imageContext", Map.of("languageHints", List.of("ko"))
|
||||
))
|
||||
);
|
||||
|
||||
String responseJson;
|
||||
try {
|
||||
responseJson = restClient.post()
|
||||
.uri(ENDPOINT + "?key=" + apiKey)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("Vision OCR 호출 실패: {}", e.getMessage());
|
||||
throw new ApiException(HttpStatus.BAD_GATEWAY, "영수증 인식 서버 호출에 실패했습니다.");
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode r0 = objectMapper.readTree(responseJson).path("responses").path(0);
|
||||
if (r0.has("error")) {
|
||||
log.warn("Vision OCR 오류 응답: {}", r0.path("error").path("message").asText());
|
||||
throw new ApiException(HttpStatus.BAD_GATEWAY, "영수증 인식에 실패했습니다.");
|
||||
}
|
||||
return r0.path("fullTextAnnotation").path("text").asText("");
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.warn("Vision OCR 응답 파싱 실패: {}", e.getMessage());
|
||||
throw new ApiException(HttpStatus.BAD_GATEWAY, "영수증 인식 결과를 처리하지 못했습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user