Compare commits
82 Commits
6e269888c2
...
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 |
@@ -36,6 +36,8 @@ jobs:
|
||||
DB_PASSWORD: ci_root_pw
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: '6379'
|
||||
# 컨텍스트 테스트(@SpringBootTest)가 스키마를 필요로 함(시작 시 기본분류 시더가 테이블 조회) → 운영과 동일하게 스키마 초기화
|
||||
SQL_INIT_MODE: always
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -46,7 +48,7 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
cache: gradle
|
||||
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
|
||||
|
||||
# clean build 는 test 태스크를 포함 — 단위테스트 실패 시 빌드/배포 차단(게이트)
|
||||
- name: Build & Test
|
||||
|
||||
@@ -18,33 +18,9 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# clean build 가 테스트(@SpringBootTest 컨텍스트 로드 포함)를 돌리므로 CI와 동일한 DB/Redis 필요
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: ci_root_pw
|
||||
MARIADB_DATABASE: sb_db
|
||||
options: >-
|
||||
--health-cmd="healthcheck.sh --connect --innodb_initialized"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=15
|
||||
redis:
|
||||
image: redis:7
|
||||
options: >-
|
||||
--health-cmd="redis-cli ping"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=10
|
||||
|
||||
env:
|
||||
DB_URL: jdbc:mariadb://mariadb:3306/sb_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
|
||||
DB_USERNAME: root
|
||||
DB_PASSWORD: ci_root_pw
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: '6379'
|
||||
|
||||
# 배포 잡은 테스트를 돌리지 않으므로 DB/Redis 서비스 컨테이너가 필요 없다.
|
||||
# → 서비스 컨테이너 후처리(cleanup) hang 으로 "가짜 실패"가 나던 문제 회피.
|
||||
# 품질(테스트) 게이트는 ci.yaml 이 같은 push 에서 함께 수행한다.
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -54,13 +30,14 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
cache: gradle
|
||||
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
|
||||
|
||||
# clean build 는 test 를 포함 — 테스트 실패 시 jar 가 생성되지 않아 배포 중단(게이트)
|
||||
- name: Build jar (테스트 포함)
|
||||
# 테스트 없이 jar 만 빌드(bootJar) — 서비스 컨테이너/컨텍스트 테스트가 없어 후처리 hang 이 없다.
|
||||
# (테스트 게이트는 ci.yaml 이 담당)
|
||||
- name: Build jar (테스트 제외, bootJar)
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew --no-daemon clean build
|
||||
./gradlew --no-daemon clean bootJar
|
||||
|
||||
- name: Deploy (SSH + systemd)
|
||||
env:
|
||||
|
||||
@@ -2,6 +2,10 @@ HELP.md
|
||||
.gradle
|
||||
build/
|
||||
|
||||
# 로컬 전용 테스트/시드 스크립트 (실제 이메일·프리미엄 부여 포함 — 커밋 금지)
|
||||
scripts/seed-test-*.sql
|
||||
scripts/cleanup-test-*.sql
|
||||
|
||||
# 환경변수 / 비밀 정보 (실제 .env 는 커밋 금지, .env.example 만 추적)
|
||||
.env
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
@@ -24,6 +24,8 @@ dependencies {
|
||||
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5'
|
||||
// 비밀번호 BCrypt 해싱 (전체 Spring Security 미도입, crypto 모듈만 사용)
|
||||
implementation 'org.springframework.security:spring-security-crypto'
|
||||
// Apple 로그인 ID 토큰(JWT) 서명 검증 — Apple JWKS(공개키) 기반
|
||||
implementation 'com.nimbusds:nimbus-jose-jwt:9.40'
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
|
||||
+61
-38
@@ -1,11 +1,11 @@
|
||||
# Slim Budget — 백엔드 (sb_bt)
|
||||
# 돈돼지 가계부 — 백엔드 (sb_bt)
|
||||
|
||||
Spring Boot 기반의 가계부·게시판 REST API 서버.
|
||||
Spring Boot 기반의 가계부·자산·게시판 REST API 서버. (구 Slim Budget)
|
||||
|
||||
## 기술 스택
|
||||
- **Spring Boot 3.5** / **Java 17**
|
||||
- **MyBatis** (XML 매퍼) + **MariaDB**
|
||||
- **Redis** (세션 저장)
|
||||
- **Redis** (세션 저장 — DB 이중 저장으로 유실 대비)
|
||||
- **spring-security-crypto** (BCrypt 비밀번호 해시)
|
||||
- Lombok, Bean Validation
|
||||
|
||||
@@ -13,74 +13,97 @@ Spring Boot 기반의 가계부·게시판 REST API 서버.
|
||||
```
|
||||
com.sb.web
|
||||
├── account # 가계부 (controller / service / mapper / domain / dto)
|
||||
├── auth # 인증 (로그인/세션, AuthInterceptor)
|
||||
├── board # 게시판
|
||||
│ └ invest·budget·category·recurring·quick·ocr·fx·backup 포함
|
||||
├── auth # 인증 (로그인/세션/구글로그인, AuthInterceptor, 포인트)
|
||||
├── billing # 인앱결제·구독(Google Play Billing 검증, RTDN)
|
||||
├── board # 게시판 (글/댓글/추천/공지/이미지)
|
||||
├── user # 사용자
|
||||
├── admin # 관리자 (태그 카테고리, 게시판 설정)
|
||||
└── common # 공통 (예외, 응답, 설정)
|
||||
├── admin # 관리자 (기본분류·게시판/가입 설정·회원 관리)
|
||||
└── common # 공통 (예외, 응답, 헬스체크, 설정)
|
||||
```
|
||||
|
||||
## 스키마 자동 초기화
|
||||
`spring.sql.init.schema-locations` 로 기동 시 적용 (멱등 — `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`):
|
||||
`spring.sql.init` 로 기동 시 적용. 기본 `mode: never`, 환경변수 **`SQL_INIT_MODE=always`** 로 켜면 실행(멱등 — `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`):
|
||||
```
|
||||
classpath:db/member.sql, db/board.sql, db/account.sql, db/dev-seed.sql
|
||||
schema-locations: db/member.sql, db/board.sql, db/account.sql, db/dev-seed.sql
|
||||
continue-on-error: true
|
||||
```
|
||||
주요 테이블: `member`, `wallet`, `account_entry`, `account_tag`, `account_entry_tag`,
|
||||
`account_category`, `budget`, `recurring`, `invest_holding`, `invest_trade`,
|
||||
`post`, `comment`, `tag`, `tag_category` 등.
|
||||
주요 테이블:
|
||||
- **회원/인증**: `member`, `auth_session`(세션 DB 백업), `point_history`, `app_setting`(가입 허용 등), `iap_purchase`(인앱결제)
|
||||
- **가계부**: `wallet`, `account_entry`, `account_category`(`parent_id` 대/소분류), `account_tag`, `account_entry_tag`, `budget`, `budget_income`, `recurring`, `quick_entry`, `invest_holding`, `invest_trade`
|
||||
- **게시판**: `post`, `comment`, `post_vote`, `comment_vote`, `post_tag`, `tag`, `tag_category`, `tag_category_board`, `board_image`, `report`
|
||||
- **기타**: `default_category`(기본 분류 템플릿)
|
||||
|
||||
## 인증 / 보안
|
||||
- 로그인 시 토큰 발급, 이후 `Authorization: Bearer <token>` 로 인증
|
||||
- 로그인/구글로그인 시 토큰 발급, 이후 `Authorization: Bearer <token>` 로 인증
|
||||
- **구글 로그인**: `POST /api/auth/google` 로 ID 토큰 수신 → **tokeninfo 검증(aud=웹 클라이언트 ID)** 후 세션 발급. 검증된 동일 이메일이면 기존 계정에 `member.google_id` 로 연결
|
||||
- `AuthInterceptor` 가 현재 사용자(`SessionUser`)를 요청 속성(`CURRENT_MEMBER`)으로 주입
|
||||
- **세션 이중 저장**: Redis 유실·재시작 시 `auth_session`(DB)에서 복원·재수화
|
||||
- **무차별 대입 방지**: 실패 시도만 카운트 — 로그인 IP당 10회/10분, 비밀번호 재인증 회원당 5회/10분 초과 시 429
|
||||
- **봇 가입 차단**: 허니팟 + IP 레이트리밋(Redis)
|
||||
- **모든 가계부 데이터는 `member_id`(소유자)로 격리** — 본인 데이터만 조회/수정
|
||||
- DB 자격증명 등은 `.env`(gitignore)로 관리, `.env.example` 템플릿 제공
|
||||
- DB/Redis 자격증명·`ACCOUNT_CRYPTO_KEY`(계좌번호 AES-256-GCM)·`GOOGLE_CLIENT_ID` 등은 `.env`(gitignore), `.env.example` 템플릿 제공
|
||||
|
||||
## REST API 개요
|
||||
|
||||
### 인증 · 사용자 · 관리자
|
||||
- `POST /api/auth/login` 외 인증 엔드포인트
|
||||
- `/api/users` — 사용자 관리
|
||||
- `/api/admin/**` — 태그 카테고리·게시판 설정(관리자)
|
||||
- `/api/board/**` — 게시판(글/댓글/태그/열람제한)
|
||||
- `/api/auth` — `login`·`signup`·`logout`·`google`·`me`(GET/DELETE) / `password`·`verify-password`·`profile`·`profile-image` / `points`·`point-history` / `signup-enabled`·`google-client-id`
|
||||
- `/api/users` — 사용자 CRUD
|
||||
- `/api/admin` — `signup-setting`(가입 허용 토글), `default-categories`(기본 분류), `members`(회원 목록·`role`·`plan`·`status` 변경·삭제), 태그 카테고리
|
||||
- `/api/billing` — `products`·`subscription`·`google/verify`·`cancel`·`resume`·`restore`·`google/rtdn`(정기 알림)
|
||||
- `/api/board` — 글/댓글 CRUD, `posts/{id}/vote`(추천), `notice`/`unnotice`(공지), `block`/`unblock`(열람제한), `my/posts`·`my/comments`, `tag-groups`, `recommenders`
|
||||
- `/api/board/images` (업로드) · `/api/images/{id}` (공개 조회) — 게시판 이미지(DB 저장, 절대 URL 반환)
|
||||
|
||||
### 가계부 `/api/account`
|
||||
| 메서드·경로 | 설명 |
|
||||
|---|---|
|
||||
| `GET/POST/PUT/DELETE /entries` | 내역 CRUD. 조회 필터: `year,month,type,category,walletId,keyword,tagId` |
|
||||
| `POST /entries/notification` `POST /entries/parse` | 카드/은행 알림 자동인식 등록 · 자연어 파싱 |
|
||||
| `GET /entries/pending/count` `POST /entries/{id}/confirm` | 확인 필요(pending) 건수 · 확정 |
|
||||
| `GET /summary` `GET /stats` | 월 요약 / 기간 버킷(일·주·월·년) 수입·지출 |
|
||||
| `GET /category-stats` | 분류별 합계 (파이차트) |
|
||||
| `GET/POST/PUT/DELETE /wallets` | 계좌/카드/대출/투자(BANK·CARD·LOAN·INVEST) |
|
||||
| `GET /networth` `GET /networth/trend` | 순자산 / 월별 순자산 추이 |
|
||||
| `GET /wallets/{id}/entries` `POST /repayment` | 계좌별 내역 / 상환·납부(원금=이체, 이자=지출) |
|
||||
| `GET/POST/PUT/DELETE /tags` | 사용자별 가계부 태그 |
|
||||
| `/api/account/categories` (+`/import`) | 분류 CRUD, 기존 내역에서 가져오기 |
|
||||
| `/api/account/budgets` (+`/status`,`/period`) | 예산 CRUD, 진행률, 기간별 |
|
||||
| `/api/account/recurrings` (+`/run`) | 정기 거래 규칙, 밀린 회차 자동 생성 |
|
||||
| `/api/account/invest/**` | 투자 포트폴리오(아래) |
|
||||
| `GET /category-stats` | 분류별 합계 (파이차트, 대/소분류) |
|
||||
| `GET/POST/PUT/DELETE /wallets` (+`/reorder`) | 계좌(BANK·CASH·CARD·LOAN·INVEST) |
|
||||
| `GET /networth` `GET /networth/trend` | 순자산 / 순자산 추이(`unit=WEEK&weeks=N` 주간 지원) |
|
||||
| `GET /wallets/{id}/entries` `POST /repayment` | 계좌별 내역 / 상환·납부(원금=이체·이자=지출·연회비=지출) |
|
||||
| `GET/POST/PUT/DELETE /tags` (+`/reorder`) | 사용자별 가계부 태그 |
|
||||
| `/categories` (+`/reorder`,`/import`) | 분류 CRUD, 순서변경, 기존 내역에서 가져오기 |
|
||||
| `/budgets` (+`/income`,`/copy`,`/status`,`/period`) | 월별 예산 CRUD, 수입, 전월 복사, 진행률, 기간별 |
|
||||
| `/recurrings` (+`/run`) | 고정지출 규칙, 밀린 회차 자동 생성 |
|
||||
| `/quick-entries` | 자주 쓰는 내역 |
|
||||
| `/invest/**` | 투자 포트폴리오(아래) |
|
||||
| `/ocr/receipt` | 영수증 OCR (Google Vision) |
|
||||
| `POST /restore` | 백업 데이터 복구 (엑셀·JSON) |
|
||||
|
||||
> 환율: `GET /api/fx/rate` — 외화 입력 시 환율 자동조회(원화 환산).
|
||||
|
||||
### 잔액 · 순자산 모델
|
||||
- 부호 모델: 자산(+) / 부채(−)
|
||||
- 부호 모델: 자산(+) / 부채(−). 현금(CASH)은 은행(BANK)과 동일하게 자산 합산
|
||||
- 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금 (`WalletMapper.findBalances`)
|
||||
- 순자산 = Σ자산 − Σ부채. 투자계좌는 **예수금 + 주식 평가금액**으로 반영
|
||||
- 순자산 추이: 현금흐름 누적(이체는 상쇄) + 투자 손익은 현재 시점에 가산
|
||||
- 순자산 추이: 현금흐름 누적(이체는 상쇄) + 투자 손익은 현재 시점에 가산. 주간(월요일 기준) 집계 지원
|
||||
|
||||
### 정기 거래 (recurring)
|
||||
### 고정지출 (recurring)
|
||||
- 빈도(DAILY/WEEKLY/MONTHLY/YEARLY) + 일/요일/월 지정
|
||||
- `last_run_date` 커서로 **중복 없이** 밀린 회차를 따라잡아 `account_entry` 생성
|
||||
- `last_run_date` 커서로 **중복 없이** 밀린 회차를 따라잡아 `account_entry` 생성. 자동 생성분은 **'확인 필요'** 상태로 만들어 실제 처리 확인 후 확정
|
||||
|
||||
### 투자 포트폴리오 (`/api/account/invest`) — 종목 단위(C)
|
||||
### 투자 포트폴리오 (`/api/account/invest`) — 종목 단위
|
||||
| 경로 | 설명 |
|
||||
|---|---|
|
||||
| `GET/POST/PUT/DELETE /holdings` | 보유 종목 (종목명·코드·현재가). 집계: 수량·평단·평가금액·평가손익·수익률·실현손익 |
|
||||
| `GET/POST /holdings/{id}/trades`, `DELETE /trades/{id}` | 매수/매도 이력 |
|
||||
- 매매이력(시간순)으로 **이동평균 평단**·실현손익·평가손익 산출
|
||||
- 매수 시 평단 재계산 `(기존수량×평단 + 매수수량×단가 + 수수료)/총수량`, 매도 시 실현손익 = (매도가 − 평단)×수량 − 수수료
|
||||
- 매도수량이 보유수량 초과 시 400 반환
|
||||
| `GET/POST/PUT/DELETE /holdings` | 보유 종목(종목명·코드·현재가). 집계: 수량·평단·평가금액·평가손익·수익률·실현손익 |
|
||||
| `GET/POST /holdings/{id}/trades`, `PUT/DELETE /trades/{id}` | 매수/매도 이력 (수정 시 시점별 시뮬레이션으로 보유수량 음수 방지) |
|
||||
| `POST /holdings/refresh-prices`, `POST /refresh-prices` | 종목코드로 현재가 자동 갱신(네이버 금융) |
|
||||
- 매매이력(시간순)으로 **이동평균 평단**·실현손익·평가손익 산출. 매도수량이 보유수량 초과 시 400
|
||||
- 투자계좌 가치 = 예수금(현금 ± 매매) + 주식평가, 순자산/추이에 연동
|
||||
- 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외)
|
||||
- ℹ️ 백엔드는 종목 단위 API를 유지하나, **현재 프론트 UI 는 투자금+평가액만 입력하는 단순형**으로 노출
|
||||
|
||||
## 개발/실행
|
||||
```bash
|
||||
cp .env.example .env # DB/Redis 자격증명 입력
|
||||
cp .env.example .env # DB/Redis 자격증명 입력 (SQL_INIT_MODE=always 로 스키마 자동생성)
|
||||
./gradlew bootRun # http://localhost:8080
|
||||
./gradlew test # 단위 + @WebMvcTest 통합 테스트
|
||||
```
|
||||
|
||||
## 배포
|
||||
- `main` 푸시 → Gitea Actions 가 CI(빌드·테스트) + Deploy(jar 전송·systemd 재시작) 실행. 테스트 실패 시 배포 중단
|
||||
- 상세: [deploy/README.md](../deploy/README.md)
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper"})
|
||||
@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper", "com.sb.web.point.mapper", "com.sb.web.billing.mapper"})
|
||||
public class SbBtApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -56,7 +56,9 @@ public class AccountController {
|
||||
public ResponseEntity<WalletResponse> createWallet(
|
||||
@Valid @RequestBody WalletRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(accountService.createWallet(req, current.getId()));
|
||||
boolean premium = "ADMIN".equals(current.getRole()) || "PREMIUM".equals(current.getPlan());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(accountService.createWallet(req, current.getId(), premium));
|
||||
}
|
||||
|
||||
@PutMapping("/wallets/{id}")
|
||||
@@ -250,6 +252,14 @@ public class AccountController {
|
||||
return accountService.parseText(req);
|
||||
}
|
||||
|
||||
/** 통계 화면 집계 → AI 재무 코멘트. PREMIUM 무제한, FREE 는 크레딧 차감. */
|
||||
@PostMapping("/ai-comment")
|
||||
public com.sb.web.account.dto.AiCommentResponse aiComment(
|
||||
@RequestBody com.sb.web.account.dto.AiCommentRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.aiComment(req, current);
|
||||
}
|
||||
|
||||
/** 미확인 내역 확정 (분류/계좌 보정) */
|
||||
@PostMapping("/entries/{id}/confirm")
|
||||
public AccountEntryResponse confirm(
|
||||
|
||||
@@ -6,10 +6,11 @@ import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
@@ -30,4 +31,11 @@ public class BackupController {
|
||||
backupService.restore(current.getId(), req);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/all")
|
||||
public ResponseEntity<Void> deleteAll(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
backupService.deleteAll(current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.BudgetOnetimeRequest;
|
||||
import com.sb.web.account.dto.BudgetOnetimeResponse;
|
||||
import com.sb.web.account.dto.BudgetPeriodStat;
|
||||
import com.sb.web.account.dto.BudgetRequest;
|
||||
import com.sb.web.account.dto.BudgetResponse;
|
||||
import com.sb.web.account.dto.BudgetStatus;
|
||||
import com.sb.web.account.dto.ExpectedIncomeRequest;
|
||||
import com.sb.web.account.service.AccountSettingService;
|
||||
import com.sb.web.account.service.BudgetOnetimeService;
|
||||
import com.sb.web.account.service.BudgetService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
@@ -26,6 +29,7 @@ import java.util.List;
|
||||
public class BudgetController {
|
||||
|
||||
private final BudgetService budgetService;
|
||||
private final BudgetOnetimeService onetimeService;
|
||||
private final AccountSettingService settingService;
|
||||
|
||||
/** 월 예상 수입 조회 (연·월별) */
|
||||
@@ -50,8 +54,22 @@ public class BudgetController {
|
||||
|
||||
@GetMapping
|
||||
public List<BudgetResponse> list(
|
||||
@RequestParam int year,
|
||||
@RequestParam int month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return budgetService.list(current.getId());
|
||||
return budgetService.list(current.getId(), year, month);
|
||||
}
|
||||
|
||||
/** 다른 월의 예산을 이 달로 복사(전월 복사 등). 같은 분류는 덮어씀 */
|
||||
@PostMapping("/copy")
|
||||
public List<BudgetResponse> copy(
|
||||
@RequestParam int fromYear,
|
||||
@RequestParam int fromMonth,
|
||||
@RequestParam int toYear,
|
||||
@RequestParam int toMonth,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
budgetService.copy(current.getId(), fromYear, fromMonth, toYear, toMonth);
|
||||
return budgetService.list(current.getId(), toYear, toMonth);
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
@@ -74,9 +92,11 @@ public class BudgetController {
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<BudgetResponse> create(
|
||||
@RequestParam int year,
|
||||
@RequestParam int month,
|
||||
@Valid @RequestBody BudgetRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(budgetService.create(req, current.getId()));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(budgetService.create(req, current.getId(), year, month));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@@ -94,4 +114,35 @@ public class BudgetController {
|
||||
budgetService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ===== 1회성 예산 =====
|
||||
|
||||
@GetMapping("/onetime")
|
||||
public List<BudgetOnetimeResponse> onetimeList(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return onetimeService.list(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/onetime")
|
||||
public ResponseEntity<BudgetOnetimeResponse> onetimeCreate(
|
||||
@Valid @RequestBody BudgetOnetimeRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(onetimeService.create(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/onetime/{id}")
|
||||
public BudgetOnetimeResponse onetimeUpdate(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody BudgetOnetimeRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return onetimeService.update(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/onetime/{id}")
|
||||
public ResponseEntity<Void> onetimeDelete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
onetimeService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.ParsedEntryResponse;
|
||||
import com.sb.web.account.service.AiEntryParser;
|
||||
import com.sb.web.account.service.VisionOcrService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
@@ -14,11 +16,15 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 영수증 OCR API (/api/account/ocr/** — 로그인 필요).
|
||||
* 이미지 업로드 → Google Vision 으로 텍스트 추출 → 프론트에서 금액·날짜·상호 파싱.
|
||||
* - /receipt : Google Vision 텍스트 추출(프론트 규칙 파서용, 폴백)
|
||||
* - /receipt-ai: Claude Vision 으로 영수증을 구조화(금액·상호·날짜·분류·계좌). AI 미설정/실패 시 recognized=false.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/account/ocr")
|
||||
@@ -26,6 +32,7 @@ import java.util.Map;
|
||||
public class OcrController {
|
||||
|
||||
private final VisionOcrService visionOcrService;
|
||||
private final AiEntryParser aiEntryParser;
|
||||
|
||||
@PostMapping("/receipt")
|
||||
public Map<String, String> receipt(
|
||||
@@ -37,4 +44,34 @@ public class OcrController {
|
||||
String text = visionOcrService.recognize(image.getBytes());
|
||||
return Map.of("text", text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영수증 이미지 → AI 구조화. AI 미설정/실패/미인식이면 recognized=false 로 반환(프론트가 /receipt 폴백).
|
||||
* 분류·계좌 후보는 멀티파트 파트 수 제한(Tomcat 기본 10개)을 피하려 '줄바꿈'으로 합친 단일 필드로 받는다.
|
||||
*/
|
||||
@PostMapping("/receipt-ai")
|
||||
public ParsedEntryResponse receiptAi(
|
||||
@RequestParam("image") MultipartFile image,
|
||||
@RequestParam(value = "categoryHints", required = false) String categoryHints,
|
||||
@RequestParam(value = "walletHints", required = false) String walletHints,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) throws IOException {
|
||||
if (image == null || image.isEmpty()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "영수증 이미지를 첨부하세요.");
|
||||
}
|
||||
return aiEntryParser
|
||||
.parseReceipt(image.getBytes(), image.getContentType(), LocalDate.now(),
|
||||
splitLines(categoryHints), splitLines(walletHints))
|
||||
.orElseGet(() -> ParsedEntryResponse.builder().recognized(false).build());
|
||||
}
|
||||
|
||||
/** 줄바꿈으로 합쳐 온 후보 문자열 → 리스트(공백 항목 제거). null/빈 값이면 빈 리스트. */
|
||||
private static List<String> splitLines(String joined) {
|
||||
if (joined == null || joined.isBlank()) return List.of();
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String s : joined.split("\n")) {
|
||||
String t = s.trim();
|
||||
if (!t.isEmpty()) out.add(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ public class RecurringController {
|
||||
public ResponseEntity<RecurringResponse> create(
|
||||
@Valid @RequestBody RecurringRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(recurringService.create(req, current.getId()));
|
||||
boolean premium = "ADMIN".equals(current.getRole()) || "PREMIUM".equals(current.getPlan());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(recurringService.create(req, current.getId(), premium));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.ShopStatusResponse;
|
||||
import com.sb.web.account.service.ShopService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/account/shop")
|
||||
@RequiredArgsConstructor
|
||||
public class ShopController {
|
||||
|
||||
private final ShopService shopService;
|
||||
|
||||
@GetMapping("/status")
|
||||
public ShopStatusResponse status(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return shopService.getStatus(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/buy")
|
||||
public ShopStatusResponse buy(
|
||||
@RequestBody Map<String, String> body,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
String item = body.get("item");
|
||||
return switch (item) {
|
||||
case "WALLET_SLOT" -> shopService.buyWalletSlot(current.getId());
|
||||
case "AI_STAT" -> shopService.buyAiStatPack(current.getId());
|
||||
default -> throw new org.springframework.web.server.ResponseStatusException(
|
||||
org.springframework.http.HttpStatus.BAD_REQUEST, "SHOP_UNKNOWN_ITEM");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -30,6 +31,9 @@ public class AccountEntry implements Serializable {
|
||||
private Long toWalletId; // 이체 입금 계좌
|
||||
private String toWalletName;
|
||||
private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null)
|
||||
private String currency; // 외화 통화코드(USD 등). null/KRW = 원화
|
||||
private BigDecimal originalAmount; // 외화 원본 금액
|
||||
private BigDecimal exchangeRate; // 적용 환율(외화 1단위→원화)
|
||||
private Boolean pending; // 확인 필요(미확정) 여부 — 알림 자동인식 건
|
||||
private String notifKey; // 알림 자동인식 중복방지 키
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@@ -28,6 +28,8 @@ public class Budget implements Serializable {
|
||||
private Long weekly;
|
||||
private Long monthly;
|
||||
private Long yearly;
|
||||
private Integer year; // 예산 연도(월별 예산)
|
||||
private Integer month; // 예산 월(1~12)
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 1회성 예산 — 특정 기간(start_date~end_date)에 단발 예산을 설정.
|
||||
* category=null 이면 전체 지출 합산, 값 있으면 해당 카테고리만.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BudgetOnetime {
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String title;
|
||||
private String category; // null = 전체
|
||||
private Long amount;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -29,6 +30,12 @@ public class Wallet implements Serializable {
|
||||
private Long openingBalance; // 초기 잔액(부호있음: 자산+, 부채-)
|
||||
private LocalDate openingDate;
|
||||
private Long currentValue; // 현재 평가금액 (INVEST 전용, 수동 갱신)
|
||||
private Long loanAmount; // 대출 실행 금액(원금, 처음 빌린 금액) (LOAN 전용)
|
||||
private BigDecimal loanRate; // 연이자율(%) e.g. 5.25 (LOAN 전용)
|
||||
private String loanMethod; // 상환방식: EQUAL_PAYMENT / EQUAL_PRINCIPAL / BULLET (LOAN 전용)
|
||||
private Integer loanMonths; // 대출기간(개월) (LOAN 전용)
|
||||
private LocalDate loanStart; // 대출시작일 (LOAN 전용)
|
||||
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 은행 조회액과 정확히 맞추기 위한 선택 입력 (LOAN 전용)
|
||||
private Integer sortOrder; // 표시 순서 (타입 내 정렬)
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@@ -48,4 +49,10 @@ public class AccountEntryRequest {
|
||||
|
||||
/** 선택한 태그 ID 목록 (태그 관리에 등록된 태그, 선택) */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 외화 결제(선택). currency 가 있으면 amount 는 환산된 원화여야 한다. */
|
||||
@Size(max = 3, message = "통화코드가 올바르지 않습니다.")
|
||||
private String currency; // USD/JPY 등. null/KRW = 원화
|
||||
private BigDecimal originalAmount; // 외화 원본 금액
|
||||
private BigDecimal exchangeRate; // 적용 환율(외화 1단위→원화)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.sb.web.account.domain.AccountEntry;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@@ -26,6 +27,9 @@ public class AccountEntryResponse {
|
||||
private String toWalletName;
|
||||
private Integer installmentMonths;
|
||||
private Boolean pending;
|
||||
private String currency;
|
||||
private BigDecimal originalAmount;
|
||||
private BigDecimal exchangeRate;
|
||||
private List<String> tags;
|
||||
|
||||
public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
|
||||
@@ -42,6 +46,9 @@ public class AccountEntryResponse {
|
||||
.toWalletName(e.getToWalletName())
|
||||
.installmentMonths(e.getInstallmentMonths())
|
||||
.pending(Boolean.TRUE.equals(e.getPending()))
|
||||
.currency(e.getCurrency())
|
||||
.originalAmount(e.getOriginalAmount())
|
||||
.exchangeRate(e.getExchangeRate())
|
||||
.tags(tags)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 통계 화면의 이번 달 집계(원 단위) — AI 재무 코멘트 생성용. 거래 원문은 보내지 않고 집계만 전달.
|
||||
*/
|
||||
@Data
|
||||
public class AiCommentRequest {
|
||||
|
||||
private int year;
|
||||
private int month;
|
||||
private long income; // 이번 달 수입 합계
|
||||
private long expense; // 이번 달 지출 합계
|
||||
private long prevExpense; // 직전 달 지출 합계(대비용)
|
||||
private long budget; // 이번 달 예산 합계
|
||||
private long spent; // 이번 달 예산 대비 소비액
|
||||
private List<CategoryAmount> categories; // 분류별 지출 상위
|
||||
|
||||
@Data
|
||||
public static class CategoryAmount {
|
||||
private String category;
|
||||
private long total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 재무 코멘트 결과. bullets 가 비어 있으면 통계 화면에서 카드를 숨긴다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class AiCommentResponse {
|
||||
|
||||
private List<String> bullets; // 현황 관찰/코멘트
|
||||
private List<String> tips; // 절약 가이드(실행 가능한 제안)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
public class BudgetOnetimeRequest {
|
||||
|
||||
@NotBlank(message = "제목을 입력하세요.")
|
||||
@Size(max = 100, message = "제목은 100자 이내여야 합니다.")
|
||||
private String title;
|
||||
|
||||
@Size(max = 50, message = "카테고리는 50자 이내여야 합니다.")
|
||||
private String category; // null 또는 빈값 = 전체 지출
|
||||
|
||||
@NotNull(message = "금액을 입력하세요.")
|
||||
@Positive(message = "금액은 0보다 커야 합니다.")
|
||||
private Long amount;
|
||||
|
||||
@NotNull(message = "시작일을 입력하세요.")
|
||||
private LocalDate startDate;
|
||||
|
||||
@NotNull(message = "종료일을 입력하세요.")
|
||||
private LocalDate endDate;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.BudgetOnetime;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class BudgetOnetimeResponse {
|
||||
private Long id;
|
||||
private String title;
|
||||
private String category; // null = 전체
|
||||
private Long amount;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private long spent;
|
||||
private long remaining;
|
||||
private String status; // UPCOMING / ACTIVE / ENDED
|
||||
|
||||
public static BudgetOnetimeResponse from(BudgetOnetime b, long spent) {
|
||||
LocalDate today = LocalDate.now();
|
||||
String status;
|
||||
if (today.isBefore(b.getStartDate())) status = "UPCOMING";
|
||||
else if (today.isAfter(b.getEndDate())) status = "ENDED";
|
||||
else status = "ACTIVE";
|
||||
|
||||
return BudgetOnetimeResponse.builder()
|
||||
.id(b.getId())
|
||||
.title(b.getTitle())
|
||||
.category(b.getCategory())
|
||||
.amount(b.getAmount())
|
||||
.startDate(b.getStartDate())
|
||||
.endDate(b.getEndDate())
|
||||
.spent(spent)
|
||||
.remaining(b.getAmount() - spent)
|
||||
.status(status)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 카드 결제 푸시 알림 원문 (네이티브 알림 리스너 → 백엔드).
|
||||
* 빠른입력(자연어) 파싱 시에는 사용자의 분류·계좌 후보 목록을 함께 실어 AI가 그 중에서 고르게 한다.
|
||||
*/
|
||||
@Data
|
||||
public class NotificationRequest {
|
||||
@@ -16,4 +19,10 @@ public class NotificationRequest {
|
||||
|
||||
/** 알림 본문 */
|
||||
private String text;
|
||||
|
||||
/** (선택) AI 파싱용 분류 후보 이름 목록 — 이 중에서 카테고리를 고르게 함(없으면 미추천) */
|
||||
private List<String> categoryHints;
|
||||
|
||||
/** (선택) AI 파싱용 결제수단(계좌) 후보 이름 목록 — 텍스트에 결제수단 언급 시 이 중에서 고름 */
|
||||
private List<String> walletHints;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ import java.time.LocalDate;
|
||||
public class ParsedEntryResponse {
|
||||
|
||||
private boolean recognized; // 거래로 인식되었는가
|
||||
private String type; // INCOME / EXPENSE
|
||||
private String type; // INCOME / EXPENSE / TRANSFER
|
||||
private long amount;
|
||||
private String merchant; // 가맹점/적요(메모 후보)
|
||||
private LocalDate date;
|
||||
private String category; // AI 추천 분류(후보 목록 중). 없으면 빈 문자열
|
||||
private String wallet; // AI 추천 계좌(후보 중). 지출/수입=결제수단, 이체=보내는(출금) 계좌
|
||||
private String toWallet; // 이체 시 받는(입금) 계좌(후보 중). 아니면 빈 문자열
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class ShopStatusResponse {
|
||||
private int extraWalletSlots;
|
||||
private int aiStatCredits;
|
||||
private int maxExtraSlots;
|
||||
private int walletSlotPrice;
|
||||
private int aiStatPrice;
|
||||
private int aiStatPackSize;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
@@ -14,7 +17,7 @@ import java.time.LocalDate;
|
||||
public class WalletRequest {
|
||||
|
||||
@NotBlank(message = "유형을 선택하세요.")
|
||||
@Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.")
|
||||
@Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST|MINUS", message = "유형이 올바르지 않습니다.")
|
||||
private String type;
|
||||
|
||||
@NotBlank(message = "이름을 입력하세요.")
|
||||
@@ -37,4 +40,18 @@ public class WalletRequest {
|
||||
|
||||
/** 현재 평가금액 (INVEST 전용, 수동 갱신) */
|
||||
private Long currentValue;
|
||||
|
||||
// ===== 대출(LOAN) 전용 =====
|
||||
private Long loanAmount; // 대출 실행 금액(원금)
|
||||
|
||||
@DecimalMin(value = "0.0", inclusive = true)
|
||||
@DecimalMax(value = "100.0", message = "이자율은 100% 이하여야 합니다.")
|
||||
private BigDecimal loanRate; // 연이자율(%)
|
||||
|
||||
@Pattern(regexp = "EQUAL_PAYMENT|EQUAL_PRINCIPAL|BULLET|", message = "상환방식이 올바르지 않습니다.")
|
||||
private String loanMethod; // 상환방식
|
||||
|
||||
private Integer loanMonths; // 대출기간(개월)
|
||||
private LocalDate loanStart; // 대출시작일
|
||||
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 선택
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.sb.web.account.domain.Wallet;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,14 @@ public class WalletResponse {
|
||||
private Long currentValue; // 평가액 직접입력값(퇴직연금·연금 등 수동 갱신). 있으면 총평가로 사용
|
||||
private Boolean manualValuation; // true면 평가액 직접입력형(종목 자동계산 대신)
|
||||
|
||||
// 대출(LOAN) 전용
|
||||
private Long loanAmount; // 대출 실행 금액(원금)
|
||||
private BigDecimal loanRate; // 연이자율(%) e.g. 5.25
|
||||
private String loanMethod; // EQUAL_PAYMENT / EQUAL_PRINCIPAL / BULLET
|
||||
private Integer loanMonths; // 대출기간(개월)
|
||||
private LocalDate loanStart; // 대출시작일
|
||||
private Long loanPayment; // 월 상환금(고정, 원리금균등) — 선택 입력
|
||||
|
||||
public static WalletResponse from(Wallet w, long balance) {
|
||||
return WalletResponse.builder()
|
||||
.id(w.getId())
|
||||
@@ -43,6 +52,12 @@ public class WalletResponse {
|
||||
.openingDate(w.getOpeningDate())
|
||||
.balance(balance)
|
||||
.currentValue(w.getCurrentValue())
|
||||
.loanAmount(w.getLoanAmount())
|
||||
.loanRate(w.getLoanRate())
|
||||
.loanMethod(w.getLoanMethod())
|
||||
.loanMonths(w.getLoanMonths())
|
||||
.loanStart(w.getLoanStart())
|
||||
.loanPayment(w.getLoanPayment())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,4 +93,12 @@ public interface AccountEntryMapper {
|
||||
int deleteEntryTagsByEntryId(@Param("entryId") Long entryId);
|
||||
|
||||
List<String> findTagNamesByEntryId(@Param("entryId") Long entryId);
|
||||
|
||||
/** 복수 항목의 태그를 한 번에 조회 — {entryId, tagName} 맵 반환 */
|
||||
List<Map<String, Object>> findTagsByEntryIds(@Param("entryIds") List<Long> entryIds);
|
||||
|
||||
/** 특정 연·월에 기록이 있는 서로 다른 날짜 수 (한 달 개근 판정용) */
|
||||
int countDistinctEntryDays(@Param("memberId") Long memberId,
|
||||
@Param("year") int year,
|
||||
@Param("month") int month);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public interface BackupMapper {
|
||||
int deleteRecurrings(@Param("memberId") Long memberId);
|
||||
int deleteBudgets(@Param("memberId") Long memberId);
|
||||
int deleteBudgetIncome(@Param("memberId") Long memberId);
|
||||
int deleteBudgetOnetime(@Param("memberId") Long memberId);
|
||||
int deleteQuickEntries(@Param("memberId") Long memberId);
|
||||
int deleteInvestTrades(@Param("memberId") Long memberId);
|
||||
int deleteInvestHoldings(@Param("memberId") Long memberId);
|
||||
|
||||
@@ -12,13 +12,24 @@ import java.util.List;
|
||||
@Mapper
|
||||
public interface BudgetMapper {
|
||||
|
||||
List<Budget> findByMember(@Param("memberId") Long memberId);
|
||||
List<Budget> findByMember(@Param("memberId") Long memberId,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
Budget findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
int countByCategory(@Param("memberId") Long memberId,
|
||||
@Param("category") String category,
|
||||
@Param("excludeId") Long excludeId);
|
||||
@Param("excludeId") Long excludeId,
|
||||
@Param("year") Integer year,
|
||||
@Param("month") Integer month);
|
||||
|
||||
/** fromYear/fromMonth 예산을 toYear/toMonth 로 복사(같은 분류는 덮어씀). 복사된 건수 반환 */
|
||||
int copyMonth(@Param("memberId") Long memberId,
|
||||
@Param("fromYear") Integer fromYear,
|
||||
@Param("fromMonth") Integer fromMonth,
|
||||
@Param("toYear") Integer toYear,
|
||||
@Param("toMonth") Integer toMonth);
|
||||
|
||||
int insert(Budget budget);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.BudgetOnetime;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface BudgetOnetimeMapper {
|
||||
|
||||
List<BudgetOnetime> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
BudgetOnetime findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
long sumExpenseInRange(@Param("memberId") Long memberId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
@Param("category") String category);
|
||||
|
||||
void insert(BudgetOnetime b);
|
||||
|
||||
void update(BudgetOnetime b);
|
||||
|
||||
void delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -15,6 +15,9 @@ public interface RecurringMapper {
|
||||
|
||||
List<Recurring> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
/** 회원의 정기 거래 개수 (무료 회원 등록 한도 판정용) */
|
||||
int countByMember(@Param("memberId") Long memberId);
|
||||
|
||||
/** 활성 규칙 (자동 생성용) */
|
||||
List<Recurring> findActiveByMember(@Param("memberId") Long memberId);
|
||||
|
||||
|
||||
@@ -31,4 +31,7 @@ public interface WalletMapper {
|
||||
|
||||
/** 해당 타입의 현재 최대 sort_order (신규 계좌를 맨 뒤에 배치) */
|
||||
Integer maxSortOrder(@Param("memberId") Long memberId, @Param("type") String type);
|
||||
|
||||
/** 해당 타입의 계좌 개수 (무료 회원 종류별 등록 한도 판정용) */
|
||||
int countByType(@Param("memberId") Long memberId, @Param("type") String type);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ import com.sb.web.account.dto.WalletResponse;
|
||||
import com.sb.web.account.mapper.AccountEntryMapper;
|
||||
import com.sb.web.account.mapper.AccountTagMapper;
|
||||
import com.sb.web.account.mapper.WalletMapper;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.mapper.MemberMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import com.sb.web.point.PointService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -48,14 +51,29 @@ public class AccountService {
|
||||
private final WalletMapper walletMapper;
|
||||
private final InvestService investService;
|
||||
private final CardNotificationParser notificationParser;
|
||||
private final AiEntryParser aiEntryParser;
|
||||
private final PointService pointService;
|
||||
private final MemberMapper memberMapper;
|
||||
|
||||
/* ===================== 항목 ===================== */
|
||||
|
||||
public List<AccountEntryResponse> list(Long memberId, Integer year, Integer month,
|
||||
String type, String category, Long walletId, String keyword, Long tagId) {
|
||||
return mapper.findByMember(memberId, year, month,
|
||||
blankToNull(type), blankToNull(category), walletId, blankToNull(keyword), tagId).stream()
|
||||
.map(this::toResponse)
|
||||
List<AccountEntry> entries = mapper.findByMember(memberId, year, month,
|
||||
blankToNull(type), blankToNull(category), walletId, blankToNull(keyword), tagId);
|
||||
if (entries.isEmpty()) return List.of();
|
||||
// 태그 N+1 방지: 한 번에 모든 entry 의 태그를 조회 후 Map 으로 그룹화
|
||||
List<Long> ids = entries.stream().map(AccountEntry::getId).toList();
|
||||
Map<Long, List<String>> tagsByEntry = new HashMap<>();
|
||||
for (Map<String, Object> row : mapper.findTagsByEntryIds(ids)) {
|
||||
Object rawId = row.get("entry_id");
|
||||
String name = (String) row.get("tag_name");
|
||||
if (rawId == null || name == null) continue;
|
||||
Long eid = ((Number) rawId).longValue();
|
||||
tagsByEntry.computeIfAbsent(eid, k -> new ArrayList<>()).add(name);
|
||||
}
|
||||
return entries.stream()
|
||||
.map(e -> AccountEntryResponse.from(e, tagsByEntry.getOrDefault(e.getId(), List.of())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -234,12 +252,35 @@ public class AccountService {
|
||||
.walletId(req.getWalletId())
|
||||
.toWalletId(transfer ? req.getToWalletId() : null)
|
||||
.installmentMonths(installmentOf(req))
|
||||
.currency(req.getCurrency())
|
||||
.originalAmount(req.getOriginalAmount())
|
||||
.exchangeRate(req.getExchangeRate())
|
||||
.build();
|
||||
mapper.insert(entry);
|
||||
applyTags(entry.getId(), req.getTagIds(), memberId);
|
||||
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);
|
||||
@@ -253,6 +294,9 @@ public class AccountService {
|
||||
entry.setWalletId(req.getWalletId());
|
||||
entry.setToWalletId(transfer ? req.getToWalletId() : null);
|
||||
entry.setInstallmentMonths(installmentOf(req));
|
||||
entry.setCurrency(req.getCurrency());
|
||||
entry.setOriginalAmount(req.getOriginalAmount());
|
||||
entry.setExchangeRate(req.getExchangeRate());
|
||||
mapper.update(entry);
|
||||
|
||||
mapper.deleteEntryTagsByEntryId(id);
|
||||
@@ -278,10 +322,19 @@ public class AccountService {
|
||||
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
|
||||
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
|
||||
*/
|
||||
/** 문자/푸시 텍스트만 파싱해 폼 자동채움용 결과 반환 (저장 안 함). iPhone 등 알림 자동수집 불가 대비. */
|
||||
/**
|
||||
* 문자/푸시·자연어 한 줄 텍스트를 파싱해 폼 자동채움용 결과 반환 (저장 안 함).
|
||||
* AI 파서(Claude Haiku)가 설정돼 있으면 우선 시도("어제 스타벅스 5천원" 같은 자유 입력 처리),
|
||||
* 미설정/실패/미인식이면 기존 규칙기반 카드알림 파서로 폴백.
|
||||
*/
|
||||
public ParsedEntryResponse parseText(NotificationRequest req) {
|
||||
CardNotificationParser.Parsed p =
|
||||
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
|
||||
java.time.LocalDate today = java.time.LocalDate.now();
|
||||
java.util.Optional<ParsedEntryResponse> ai =
|
||||
aiEntryParser.parse(req.getText(), today, req.getCategoryHints(), req.getWalletHints());
|
||||
if (ai.isPresent() && ai.get().isRecognized()) {
|
||||
return ai.get();
|
||||
}
|
||||
CardNotificationParser.Parsed p = notificationParser.parse(req.getTitle(), req.getText(), today);
|
||||
return ParsedEntryResponse.builder()
|
||||
.recognized(p.isRecognized())
|
||||
.type(p.isIncome() ? "INCOME" : "EXPENSE")
|
||||
@@ -291,6 +344,32 @@ public class AccountService {
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 통계 화면 집계 → AI 재무 코멘트.
|
||||
* - PREMIUM: 무제한.
|
||||
* - FREE + ai_stat_credits > 0: 크레딧 1 차감 후 호출.
|
||||
* - FREE + credits == 0: 402 에러(프론트가 상점으로 유도).
|
||||
*/
|
||||
@Transactional
|
||||
public com.sb.web.account.dto.AiCommentResponse aiComment(
|
||||
com.sb.web.account.dto.AiCommentRequest req, SessionUser current) {
|
||||
if ("FREE".equals(current.getPlan())) {
|
||||
int credits = memberMapper.getAiStatCredits(current.getId());
|
||||
if (credits <= 0) {
|
||||
throw new ApiException(HttpStatus.PAYMENT_REQUIRED, "AI_STAT_CREDIT_EMPTY");
|
||||
}
|
||||
memberMapper.decrementAiStatCredit(current.getId());
|
||||
}
|
||||
try {
|
||||
String json = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(req);
|
||||
return aiEntryParser.financeComment(json);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
return com.sb.web.account.dto.AiCommentResponse.builder()
|
||||
.bullets(java.util.List.of()).tips(java.util.List.of()).build();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
|
||||
CardNotificationParser.Parsed p =
|
||||
@@ -495,8 +574,27 @@ public class AccountService {
|
||||
.build();
|
||||
}
|
||||
|
||||
// 무료 회원 계좌 등록 한도: 종류(은행·현금·카드·대출·증권·마이너스)별 기본 2개.
|
||||
// 포인트 상점(추후)에서 영구 보너스 슬롯을 구매하면 종류별 최대 5개까지 확장 가능.
|
||||
public static final int FREE_WALLET_BASE_LIMIT = 2;
|
||||
public static final int FREE_WALLET_MAX_LIMIT = 5;
|
||||
|
||||
/** 무료 회원의 해당 타입 등록 한도 = 기본 2 + 포인트 상점 구매 슬롯, 최대 5. */
|
||||
private int freeWalletLimit(Long memberId, String type) {
|
||||
int bonus = memberMapper.getExtraWalletSlots(memberId);
|
||||
return Math.min(FREE_WALLET_BASE_LIMIT + bonus, FREE_WALLET_MAX_LIMIT);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WalletResponse createWallet(WalletRequest req, Long memberId) {
|
||||
public WalletResponse createWallet(WalletRequest req, Long memberId, boolean premium) {
|
||||
// 유료(또는 관리자)는 무제한. 무료는 종류별 한도 검증.
|
||||
if (!premium) {
|
||||
int limit = freeWalletLimit(memberId, req.getType());
|
||||
if (walletMapper.countByType(memberId, req.getType()) >= limit) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST,
|
||||
"무료 회원은 종류별 계좌를 " + limit + "개까지 등록할 수 있어요. 유료로 업그레이드하면 무제한이에요.");
|
||||
}
|
||||
}
|
||||
Wallet wallet = toWallet(req);
|
||||
wallet.setMemberId(memberId);
|
||||
Integer max = walletMapper.maxSortOrder(memberId, req.getType());
|
||||
@@ -524,11 +622,18 @@ public class AccountService {
|
||||
wallet.setType(req.getType());
|
||||
wallet.setName(req.getName().trim());
|
||||
wallet.setIssuer(blankToNull(req.getIssuer()));
|
||||
wallet.setAccountNumber("BANK".equals(req.getType()) ? blankToNull(req.getAccountNumber()) : null);
|
||||
wallet.setAccountNumber(("BANK".equals(req.getType()) || "MINUS".equals(req.getType())) ? blankToNull(req.getAccountNumber()) : null);
|
||||
wallet.setCardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null);
|
||||
wallet.setOpeningBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L);
|
||||
wallet.setOpeningDate(req.getOpeningDate());
|
||||
wallet.setCurrentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null);
|
||||
boolean isLoan = "LOAN".equals(req.getType());
|
||||
wallet.setLoanAmount(isLoan ? req.getLoanAmount() : null);
|
||||
wallet.setLoanRate(isLoan ? req.getLoanRate() : null);
|
||||
wallet.setLoanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null);
|
||||
wallet.setLoanMonths(isLoan ? req.getLoanMonths() : null);
|
||||
wallet.setLoanStart(isLoan ? req.getLoanStart() : null);
|
||||
wallet.setLoanPayment(isLoan ? req.getLoanPayment() : null);
|
||||
walletMapper.update(wallet);
|
||||
return WalletResponse.from(wallet, balanceMap(memberId).getOrDefault(id, wallet.getOpeningBalance()));
|
||||
}
|
||||
@@ -558,15 +663,22 @@ public class AccountService {
|
||||
}
|
||||
|
||||
private Wallet toWallet(WalletRequest req) {
|
||||
boolean isLoan = "LOAN".equals(req.getType());
|
||||
return Wallet.builder()
|
||||
.type(req.getType())
|
||||
.name(req.getName().trim())
|
||||
.issuer(blankToNull(req.getIssuer()))
|
||||
.accountNumber("BANK".equals(req.getType()) ? blankToNull(req.getAccountNumber()) : null)
|
||||
.accountNumber(("BANK".equals(req.getType()) || "MINUS".equals(req.getType())) ? blankToNull(req.getAccountNumber()) : null)
|
||||
.cardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null)
|
||||
.openingBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L)
|
||||
.openingDate(req.getOpeningDate())
|
||||
.currentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null)
|
||||
.loanAmount(isLoan ? req.getLoanAmount() : null)
|
||||
.loanRate(isLoan ? req.getLoanRate() : null)
|
||||
.loanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null)
|
||||
.loanMonths(isLoan ? req.getLoanMonths() : null)
|
||||
.loanStart(isLoan ? req.getLoanStart() : null)
|
||||
.loanPayment(isLoan ? req.getLoanPayment() : null)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sb.web.account.dto.AiCommentResponse;
|
||||
import com.sb.web.account.dto.ParsedEntryResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 자연어 한 줄 입력("어제 스타벅스 5천원")을 Claude(Haiku)로 파싱해 폼 자동채움 필드로 변환한다.
|
||||
* 키(app.anthropic-api-key) 미설정이거나 호출 실패 시 Optional.empty() → 호출부가 규칙기반 파서로 폴백.
|
||||
* AI는 폼 prefill 용도이며 저장은 사용자 확인 후.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AiEntryParser {
|
||||
|
||||
/** Anthropic API 키. 미설정 시 AI 파싱 비활성(규칙기반으로 폴백). */
|
||||
@Value("${app.anthropic-api-key:}")
|
||||
private String apiKey;
|
||||
|
||||
/** 사용 모델. 저렴·빠른 Haiku 권장(학습 미사용). */
|
||||
@Value("${app.anthropic-model:claude-haiku-4-5}")
|
||||
private String model;
|
||||
|
||||
private final ObjectMapper om = new ObjectMapper();
|
||||
private final RestClient rest = RestClient.builder().baseUrl("https://api.anthropic.com").build();
|
||||
|
||||
public boolean enabled() {
|
||||
return apiKey != null && !apiKey.isBlank();
|
||||
}
|
||||
|
||||
/** 시작 시 AI 파서 활성 여부·키 로딩 상태를 로그로 남겨 진단을 쉽게 한다(키는 앞부분만 마스킹). */
|
||||
@jakarta.annotation.PostConstruct
|
||||
void logConfig() {
|
||||
if (enabled()) {
|
||||
String masked = apiKey.length() > 14 ? apiKey.substring(0, 14) + "…(len=" + apiKey.length() + ")" : "(len=" + apiKey.length() + ")";
|
||||
log.info("[ai-parse] 활성화됨 — model={}, key={}", model, masked);
|
||||
} else {
|
||||
log.warn("[ai-parse] 비활성(ANTHROPIC_API_KEY 미설정) — 규칙기반 파서만 사용");
|
||||
}
|
||||
}
|
||||
|
||||
/** 자유 텍스트 → 파싱 결과. 실패/미설정 시 empty. 분류·계좌 후보를 주면 AI가 그 중에서 골라 채운다. */
|
||||
public Optional<ParsedEntryResponse> parse(String text, LocalDate today,
|
||||
List<String> categoryHints, List<String> walletHints) {
|
||||
if (!enabled() || text == null || text.isBlank()) {
|
||||
log.info("[ai-parse] 건너뜀 — enabled={}, textBlank={}", enabled(), (text == null || text.isBlank()));
|
||||
return Optional.empty();
|
||||
}
|
||||
log.info("[ai-parse] 요청 수신 — text='{}'", text);
|
||||
String system = withHints("""
|
||||
너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입/이체 메모를 구조화한다.
|
||||
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
|
||||
{"recognized":boolean,"type":"INCOME"|"EXPENSE"|"TRANSFER","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열","toWallet":"문자열"}
|
||||
규칙:
|
||||
- amount: 원 단위 정수. 아라비아 숫자·한글 수 모두 해석한다.
|
||||
예) "5천원"=5000, "1만2천"=12000, "3만5천원"=35000, "천원"=1000,
|
||||
"오만원"=50000, "삼만원"=30000, "십만원"=100000, "오천원"=5000, "만원"=10000, "이만오천원"=25000.
|
||||
- type: 이체/송금/옮김/입출금 간 이동은 TRANSFER. 급여/월급/수입/입금/환불/이자/용돈 등은 INCOME. 그 외 소비는 EXPENSE(기본).
|
||||
- TRANSFER 판단: "A에서 B로 5만원", "A→B 이체", "카드값 냈어(계좌→카드)" 처럼 내 계좌 사이 이동. 이때 category는 빈 문자열.
|
||||
- merchant: 가맹점/항목명(예: 스타벅스, 점심, 택시). 이체면 메모(예: 카드대금)나 빈 문자열.
|
||||
- date: 오늘=__TODAY__ 기준 상대표현 해석(오늘/어제/그저께/엊그제 등). 명시 없으면 오늘.
|
||||
- category: 지출/수입일 때만. 거래 내용에 알맞은 분류를 아래 '분류 후보'에서 정확히 하나(예: 라면→식비, 택시→교통). 없거나 이체면 빈 문자열. 후보에 없는 값 지어내지 말 것.
|
||||
- wallet: 지출/수입이면 결제수단(출금 계좌). 이체면 '보내는(출금)' 계좌. 아래 '계좌 후보'에 있는 이름만, 언급 없으면 빈 문자열.
|
||||
- toWallet: 이체일 때 '받는(입금)' 계좌. '계좌 후보'에 있는 이름만. 이체가 아니거나 언급 없으면 빈 문자열.
|
||||
- 금액을 못 찾거나 거래가 아니면 recognized=false.
|
||||
분류 후보: __CATS__
|
||||
계좌 후보: __WALLETS__
|
||||
""", today, categoryHints, walletHints);
|
||||
return callClaude(system, text, 300, today, categoryHints, walletHints);
|
||||
}
|
||||
|
||||
/** 영수증 이미지 → 구조화(Claude Vision). 실패/미설정 시 empty → 호출부가 기존 OCR 로 폴백. */
|
||||
public Optional<ParsedEntryResponse> parseReceipt(byte[] image, String mediaType, LocalDate today,
|
||||
List<String> categoryHints, List<String> walletHints) {
|
||||
if (!enabled() || image == null || image.length == 0) {
|
||||
log.info("[ai-parse] 영수증 건너뜀 — enabled={}, empty={}", enabled(), (image == null || image.length == 0));
|
||||
return Optional.empty();
|
||||
}
|
||||
log.info("[ai-parse] 영수증 수신 — {} bytes, media={}", image.length, mediaType);
|
||||
String system = withHints("""
|
||||
너는 한국어 영수증 판독기다. 첨부된 영수증 이미지를 보고 거래 하나로 구조화한다.
|
||||
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
|
||||
{"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열"}
|
||||
규칙:
|
||||
- amount: 최종 결제/합계 금액(원 단위 정수, 부가세 포함 총액).
|
||||
- type: 영수증은 대부분 EXPENSE. 환불 영수증이면 INCOME.
|
||||
- merchant: 상호명(가맹점).
|
||||
- date: 영수증의 거래일(YYYY-MM-DD). 안 보이면 오늘=__TODAY__.
|
||||
- category: 품목/업종에 가장 알맞은 분류를 아래 '분류 후보'에서 정확히 하나 고른다. 없으면 빈 문자열. 후보에 없는 값 지어내지 말 것.
|
||||
- wallet: 영수증에 카드사/결제수단이 보이고 아래 '결제수단 후보'에 있으면 그 이름. 아니면 빈 문자열.
|
||||
- 영수증이 아니거나 금액을 못 읽으면 recognized=false.
|
||||
분류 후보: __CATS__
|
||||
결제수단 후보: __WALLETS__
|
||||
""", today, categoryHints, walletHints);
|
||||
String base64 = Base64.getEncoder().encodeToString(image);
|
||||
String mt = (mediaType == null || mediaType.isBlank()) ? "image/jpeg" : mediaType;
|
||||
List<Object> content = List.of(
|
||||
Map.of("type", "image", "source", Map.of("type", "base64", "media_type", mt, "data", base64)),
|
||||
Map.of("type", "text", "text", "이 영수증을 위 스키마로 구조화해줘."));
|
||||
return callClaude(system, content, 400, today, categoryHints, walletHints);
|
||||
}
|
||||
|
||||
/** 이번 달 집계(JSON) → 현황 코멘트 + 절약 가이드. 미설정/실패 시 빈 응답. */
|
||||
public AiCommentResponse financeComment(String summaryJson) {
|
||||
if (!enabled() || summaryJson == null || summaryJson.isBlank()) return emptyComment();
|
||||
try {
|
||||
String system = """
|
||||
너는 한국어 개인 가계부 코치다. 아래 사용자의 이번 달 집계(JSON, 금액 단위 원)를 보고 두 가지를 만든다.
|
||||
1) bullets: 현황 관찰 2~3개. 각 한 문장, 구체적 숫자 인용, 잔소리·과장 없이 담백하게.
|
||||
(예: 전월 대비 지출 증감, 예산 대비 소비 속도, 특정 분류 과다, 수입 대비 지출 비율)
|
||||
2) tips: 이 사용자의 지출 패턴에 딱 맞춘 '실행 가능한' 절약 제안 2~3개. 두루뭉술한 원칙 금지,
|
||||
구체적 분류·습관을 지목. 각 한 문장.
|
||||
단, 교통·버스·지하철·대중교통 관련 분류는 이미 정기권으로 최저 비용을 사용 중이므로 tips에서 제외한다.
|
||||
말투: 모든 문장을 정중한 종결어미로 끝낸다. 예) "~입니다", "~합니다", "~할 수 있습니다", "~하는 것이 좋습니다".
|
||||
반말·음슴체(예: "~함", "~줄이자") 금지.
|
||||
예시 — bullet: "식비가 이번 달 지출의 42%로 가장 큰 비중을 차지하고 있습니다."
|
||||
tip: "카페 지출 8만원을 주 2회로 줄이면 약 3만원을 절약할 수 있습니다."
|
||||
반드시 아래 JSON '하나만' 출력한다(마크다운/설명 금지):
|
||||
{"bullets":["관찰1","관찰2"],"tips":["절약제안1","절약제안2"]}
|
||||
데이터가 빈약하면 해당 배열을 빈 배열로.
|
||||
""";
|
||||
Map<String, Object> reqBody = Map.of(
|
||||
"model", model,
|
||||
"max_tokens", 600,
|
||||
"system", system,
|
||||
"messages", List.of(Map.of("role", "user", "content", summaryJson)));
|
||||
JsonNode resp = rest.post()
|
||||
.uri("/v1/messages")
|
||||
.header("x-api-key", apiKey)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(reqBody)
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
String out = stripFences(resp.path("content").path(0).path("text").asText("")).trim();
|
||||
log.info("[ai-comment] 원문='{}'", out);
|
||||
String json = extractJson(out);
|
||||
if (json == null) return emptyComment();
|
||||
JsonNode root = om.readTree(json);
|
||||
List<String> bullets = strArray(root.path("bullets"));
|
||||
List<String> tips = strArray(root.path("tips"));
|
||||
log.info("[ai-comment] 결과 — 코멘트 {}개, 절약팁 {}개", bullets.size(), tips.size());
|
||||
return AiCommentResponse.builder().bullets(bullets).tips(tips).build();
|
||||
} catch (Exception e) {
|
||||
log.warn("[ai-comment] 실패: {}", e.toString());
|
||||
return emptyComment();
|
||||
}
|
||||
}
|
||||
|
||||
private AiCommentResponse emptyComment() {
|
||||
return AiCommentResponse.builder().bullets(List.of()).tips(List.of()).build();
|
||||
}
|
||||
|
||||
/** JsonNode 배열 → 공백 제거한 문자열 리스트. */
|
||||
private List<String> strArray(JsonNode arr) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (arr != null && arr.isArray()) {
|
||||
for (JsonNode n : arr) {
|
||||
String s = n.asText("").trim();
|
||||
if (!s.isEmpty()) out.add(s);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 분류·계좌 후보와 오늘 날짜를 시스템 프롬프트 템플릿에 주입. */
|
||||
private String withHints(String body, LocalDate today, List<String> categoryHints, List<String> walletHints) {
|
||||
String catList = (categoryHints == null || categoryHints.isEmpty()) ? "(없음)" : String.join(", ", categoryHints);
|
||||
String walletList = (walletHints == null || walletHints.isEmpty()) ? "(없음)" : String.join(", ", walletHints);
|
||||
return body.replace("__TODAY__", today.toString()).replace("__CATS__", catList).replace("__WALLETS__", walletList);
|
||||
}
|
||||
|
||||
/** Claude Messages 호출 → 응답 JSON 파싱 → 결과. 후보에 없는 분류·계좌는 버림. 실패/미인식 시 empty. */
|
||||
private Optional<ParsedEntryResponse> callClaude(String system, Object userContent, int maxTokens,
|
||||
LocalDate today, List<String> categoryHints, List<String> walletHints) {
|
||||
try {
|
||||
Map<String, Object> reqBody = Map.of(
|
||||
"model", model,
|
||||
"max_tokens", maxTokens,
|
||||
"system", system,
|
||||
"messages", List.of(Map.of("role", "user", "content", userContent)));
|
||||
JsonNode resp = rest.post()
|
||||
.uri("/v1/messages")
|
||||
.header("x-api-key", apiKey)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(reqBody)
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
String out = stripFences(resp.path("content").path(0).path("text").asText("")).trim();
|
||||
log.info("[ai-parse] 모델 원문='{}'", out);
|
||||
// 모델이 설명문을 섞어 보내도 첫 '{'~마지막 '}' 만 추출해 JSON 파싱(견고성).
|
||||
String json = extractJson(out);
|
||||
if (json == null) {
|
||||
log.info("[ai-parse] JSON 없음 — 미인식으로 간주");
|
||||
return Optional.empty();
|
||||
}
|
||||
JsonNode j = om.readTree(json);
|
||||
String rawType = j.path("type").asText("EXPENSE").toUpperCase();
|
||||
String type = ("INCOME".equals(rawType) || "TRANSFER".equals(rawType)) ? rawType : "EXPENSE";
|
||||
LocalDate date;
|
||||
try {
|
||||
date = LocalDate.parse(j.path("date").asText());
|
||||
} catch (Exception e) {
|
||||
date = today;
|
||||
}
|
||||
boolean transfer = "TRANSFER".equals(type);
|
||||
ParsedEntryResponse result = ParsedEntryResponse.builder()
|
||||
.recognized(j.path("recognized").asBoolean(false))
|
||||
.type(type)
|
||||
.amount(Math.max(0, j.path("amount").asLong(0)))
|
||||
.merchant(j.path("merchant").asText(""))
|
||||
.date(date)
|
||||
// 이체는 분류 없음
|
||||
.category(transfer ? "" : pickFromHints(j.path("category").asText(""), categoryHints))
|
||||
.wallet(pickFromHints(j.path("wallet").asText(""), walletHints))
|
||||
.toWallet(transfer ? pickFromHints(j.path("toWallet").asText(""), walletHints) : "")
|
||||
.build();
|
||||
log.info("[ai-parse] 결과 — recognized={}, type={}, amount={}, merchant={}, date={}, category={}, wallet={}, toWallet={}",
|
||||
result.isRecognized(), result.getType(), result.getAmount(), result.getMerchant(), result.getDate(),
|
||||
result.getCategory(), result.getWallet(), result.getToWallet());
|
||||
return Optional.of(result);
|
||||
} catch (Exception e) {
|
||||
log.warn("[ai-parse] 실패 → 폴백: {}", e.toString());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** 모델이 반환한 값이 후보 목록에 실제 존재할 때만 채택(대소문자·공백 무시). 없으면 빈 문자열. */
|
||||
private String pickFromHints(String value, List<String> hints) {
|
||||
if (value == null || value.isBlank() || hints == null) return "";
|
||||
String v = value.trim();
|
||||
for (String h : hints) {
|
||||
if (h != null && h.trim().equalsIgnoreCase(v)) return h;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 설명문이 앞뒤로 붙어도 첫 '{'~마지막 '}' 구간만 잘라 JSON 만 추출. 중괄호 없으면 null. */
|
||||
private String extractJson(String s) {
|
||||
int a = s.indexOf('{');
|
||||
int b = s.lastIndexOf('}');
|
||||
if (a >= 0 && b > a) return s.substring(a, b + 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 모델이 ```json ... ``` 로 감쌌을 때 코드펜스 제거. */
|
||||
private String stripFences(String s) {
|
||||
String t = s.trim();
|
||||
if (t.startsWith("```")) {
|
||||
int nl = t.indexOf('\n');
|
||||
if (nl >= 0) t = t.substring(nl + 1);
|
||||
if (t.endsWith("```")) t = t.substring(0, t.length() - 3);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,23 @@ public class BackupService {
|
||||
private final BudgetService budgetService;
|
||||
private final QuickEntryService quickEntryService;
|
||||
|
||||
@Transactional
|
||||
public void deleteAll(Long memberId) {
|
||||
backupMapper.deleteEntryTags(memberId);
|
||||
backupMapper.deleteEntries(memberId);
|
||||
backupMapper.deleteRecurrings(memberId);
|
||||
backupMapper.deleteBudgets(memberId);
|
||||
backupMapper.deleteBudgetIncome(memberId);
|
||||
backupMapper.deleteBudgetOnetime(memberId);
|
||||
backupMapper.deleteQuickEntries(memberId);
|
||||
backupMapper.deleteInvestTrades(memberId);
|
||||
backupMapper.deleteInvestHoldings(memberId);
|
||||
backupMapper.deleteCategories(memberId);
|
||||
backupMapper.deleteTags(memberId);
|
||||
backupMapper.deleteWallets(memberId);
|
||||
log.info("[deleteAll] member={} — all account data deleted", memberId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void restore(Long memberId, RestoreRequest req) {
|
||||
if (isEmpty(req)) {
|
||||
@@ -50,6 +67,7 @@ public class BackupService {
|
||||
backupMapper.deleteRecurrings(memberId);
|
||||
backupMapper.deleteBudgets(memberId);
|
||||
backupMapper.deleteBudgetIncome(memberId);
|
||||
backupMapper.deleteBudgetOnetime(memberId);
|
||||
backupMapper.deleteQuickEntries(memberId);
|
||||
backupMapper.deleteInvestTrades(memberId);
|
||||
backupMapper.deleteInvestHoldings(memberId);
|
||||
@@ -71,7 +89,8 @@ public class BackupService {
|
||||
wr.setOpeningBalance(w.getOpeningBalance());
|
||||
wr.setOpeningDate(w.getOpeningDate());
|
||||
wr.setCurrentValue(w.getCurrentValue());
|
||||
Long newId = accountService.createWallet(wr, memberId).getId();
|
||||
// 복원은 사용자 본인 데이터 복구이므로 무료 한도(종류별 2개)를 적용하지 않는다.
|
||||
Long newId = accountService.createWallet(wr, memberId, true).getId();
|
||||
if (w.getOldId() != null) walletMap.put(w.getOldId(), newId);
|
||||
}
|
||||
}
|
||||
@@ -152,15 +171,17 @@ public class BackupService {
|
||||
rr.setStartDate(r.getStartDate());
|
||||
rr.setEndDate(r.getEndDate());
|
||||
rr.setActive(r.getActive() == null || r.getActive());
|
||||
recurringService.create(rr, memberId);
|
||||
// 복원은 본인 데이터 복구이므로 무료 한도 미적용
|
||||
recurringService.create(rr, memberId, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 예산
|
||||
// 7) 예산 — 복구 시 현재 월 예산으로 등록(백업엔 월 정보 없음)
|
||||
if (req.getBudgets() != null) {
|
||||
java.time.LocalDate today = java.time.LocalDate.now();
|
||||
for (BudgetRequest b : req.getBudgets()) {
|
||||
if (b == null || b.getCategory() == null) continue;
|
||||
budgetService.create(b, memberId);
|
||||
budgetService.create(b, memberId, today.getYear(), today.getMonthValue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.BudgetOnetime;
|
||||
import com.sb.web.account.dto.BudgetOnetimeRequest;
|
||||
import com.sb.web.account.dto.BudgetOnetimeResponse;
|
||||
import com.sb.web.account.mapper.BudgetOnetimeMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BudgetOnetimeService {
|
||||
|
||||
private final BudgetOnetimeMapper mapper;
|
||||
|
||||
public List<BudgetOnetimeResponse> list(Long memberId) {
|
||||
return mapper.findByMember(memberId).stream()
|
||||
.map(b -> {
|
||||
long spent = mapper.sumExpenseInRange(
|
||||
memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
|
||||
return BudgetOnetimeResponse.from(b, spent);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BudgetOnetimeResponse create(BudgetOnetimeRequest req, Long memberId) {
|
||||
validate(req);
|
||||
BudgetOnetime b = BudgetOnetime.builder()
|
||||
.memberId(memberId)
|
||||
.title(req.getTitle().trim())
|
||||
.category(blankToNull(req.getCategory()))
|
||||
.amount(req.getAmount())
|
||||
.startDate(req.getStartDate())
|
||||
.endDate(req.getEndDate())
|
||||
.build();
|
||||
mapper.insert(b);
|
||||
long spent = mapper.sumExpenseInRange(memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
|
||||
return BudgetOnetimeResponse.from(b, spent);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BudgetOnetimeResponse update(Long id, BudgetOnetimeRequest req, Long memberId) {
|
||||
validate(req);
|
||||
BudgetOnetime b = mustFind(id, memberId);
|
||||
b.setTitle(req.getTitle().trim());
|
||||
b.setCategory(blankToNull(req.getCategory()));
|
||||
b.setAmount(req.getAmount());
|
||||
b.setStartDate(req.getStartDate());
|
||||
b.setEndDate(req.getEndDate());
|
||||
mapper.update(b);
|
||||
long spent = mapper.sumExpenseInRange(memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
|
||||
return BudgetOnetimeResponse.from(b, spent);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
mapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
private void validate(BudgetOnetimeRequest req) {
|
||||
if (req.getEndDate().isBefore(req.getStartDate())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "종료일이 시작일보다 빠를 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private BudgetOnetime mustFind(Long id, Long memberId) {
|
||||
BudgetOnetime b = mapper.findByIdAndMember(id, memberId);
|
||||
if (b == null) throw new ApiException(HttpStatus.NOT_FOUND, "1회성 예산을 찾을 수 없습니다.");
|
||||
return b;
|
||||
}
|
||||
|
||||
private String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
}
|
||||
@@ -31,36 +31,46 @@ public class BudgetService {
|
||||
private final BudgetMapper budgetMapper;
|
||||
private final AccountEntryMapper entryMapper;
|
||||
|
||||
public List<BudgetResponse> list(Long memberId) {
|
||||
return budgetMapper.findByMember(memberId).stream().map(BudgetResponse::from).toList();
|
||||
public List<BudgetResponse> list(Long memberId, int year, int month) {
|
||||
return budgetMapper.findByMember(memberId, year, month).stream().map(BudgetResponse::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BudgetResponse create(BudgetRequest req, Long memberId) {
|
||||
public BudgetResponse create(BudgetRequest req, Long memberId, int year, int month) {
|
||||
String category = req.getCategory().trim();
|
||||
if (budgetMapper.countByCategory(memberId, category, null) > 0) {
|
||||
if (budgetMapper.countByCategory(memberId, category, null, year, month) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
|
||||
}
|
||||
Budget budget = toBudget(req, category);
|
||||
budget.setMemberId(memberId);
|
||||
budget.setYear(year);
|
||||
budget.setMonth(month);
|
||||
budgetMapper.insert(budget);
|
||||
return BudgetResponse.from(budget);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BudgetResponse update(Long id, BudgetRequest req, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
Budget existing = mustFind(id, memberId);
|
||||
String category = req.getCategory().trim();
|
||||
if (budgetMapper.countByCategory(memberId, category, id) > 0) {
|
||||
if (budgetMapper.countByCategory(memberId, category, id, existing.getYear(), existing.getMonth()) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
|
||||
}
|
||||
Budget budget = toBudget(req, category);
|
||||
budget.setId(id);
|
||||
budget.setMemberId(memberId);
|
||||
budget.setYear(existing.getYear());
|
||||
budget.setMonth(existing.getMonth());
|
||||
budgetMapper.update(budget);
|
||||
return BudgetResponse.from(budget);
|
||||
}
|
||||
|
||||
/** fromYear/fromMonth 예산을 toYear/toMonth 로 복사(같은 분류 덮어씀). 복사된 건수 반환 */
|
||||
@Transactional
|
||||
public int copy(Long memberId, int fromYear, int fromMonth, int toYear, int toMonth) {
|
||||
return budgetMapper.copyMonth(memberId, fromYear, fromMonth, toYear, toMonth);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
@@ -79,7 +89,7 @@ public class BudgetService {
|
||||
}
|
||||
|
||||
List<BudgetStatus> result = new ArrayList<>();
|
||||
for (Budget b : budgetMapper.findByMember(memberId)) {
|
||||
for (Budget b : budgetMapper.findByMember(memberId, year, month)) {
|
||||
long monthlyBudget = monthlyBudget(b, daysInMonth);
|
||||
long spent = spentByCategory.getOrDefault(b.getCategory(), 0L);
|
||||
result.add(BudgetStatus.builder()
|
||||
@@ -103,7 +113,8 @@ public class BudgetService {
|
||||
int y = year != null ? year : Year.now().getValue();
|
||||
int m = month != null ? month : 1;
|
||||
|
||||
List<Budget> budgets = budgetMapper.findByMember(memberId);
|
||||
// 기간 차트는 선택 월의 예산을 기준으로 각 버킷에 적용(월별 예산 도입 전 동작과 동일)
|
||||
List<Budget> budgets = budgetMapper.findByMember(memberId, y, m);
|
||||
Map<String, Long> expense = new HashMap<>();
|
||||
for (Map<String, Object> row : entryMapper.statsByPeriod(memberId, unit, year, month)) {
|
||||
expense.put(String.valueOf(((Number) row.get("bucket")).longValue()),
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기본분류 불러오기.
|
||||
*/
|
||||
|
||||
@@ -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,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; // 실패 시 직전 캐시라도
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,15 @@ public class RecurringService {
|
||||
return recurringMapper.findByMember(memberId).stream().map(this::toResponse).toList();
|
||||
}
|
||||
|
||||
// 무료 회원 고정지출 등록 한도(써보고 → 결제 유도). 유료·관리자는 무제한.
|
||||
public static final int FREE_RECURRING_LIMIT = 2;
|
||||
|
||||
@Transactional
|
||||
public RecurringResponse create(RecurringRequest req, Long memberId) {
|
||||
public RecurringResponse create(RecurringRequest req, Long memberId, boolean premium) {
|
||||
if (!premium && recurringMapper.countByMember(memberId) >= FREE_RECURRING_LIMIT) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST,
|
||||
"무료 회원은 고정 지출을 " + FREE_RECURRING_LIMIT + "개까지 등록할 수 있어요. 유료로 업그레이드하면 무제한이에요.");
|
||||
}
|
||||
Recurring r = toEntity(req);
|
||||
r.setMemberId(memberId);
|
||||
recurringMapper.insert(r);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.dto.ShopStatusResponse;
|
||||
import com.sb.web.auth.mapper.MemberMapper;
|
||||
import com.sb.web.point.PointService;
|
||||
import com.sb.web.point.mapper.PointMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ShopService {
|
||||
|
||||
static final int MAX_EXTRA_SLOTS = 3;
|
||||
static final int WALLET_SLOT_PRICE = 500;
|
||||
static final int AI_STAT_PRICE = 1000;
|
||||
static final int AI_STAT_PACK_SIZE = 10;
|
||||
|
||||
private static final String REASON_SHOP_WALLET_SLOT = "SHOP_WALLET_SLOT";
|
||||
private static final String REASON_SHOP_AI_STAT = "SHOP_AI_STAT";
|
||||
|
||||
private final MemberMapper memberMapper;
|
||||
private final PointMapper pointMapper;
|
||||
|
||||
public ShopStatusResponse getStatus(Long memberId) {
|
||||
return ShopStatusResponse.builder()
|
||||
.extraWalletSlots(memberMapper.getExtraWalletSlots(memberId))
|
||||
.aiStatCredits(memberMapper.getAiStatCredits(memberId))
|
||||
.maxExtraSlots(MAX_EXTRA_SLOTS)
|
||||
.walletSlotPrice(WALLET_SLOT_PRICE)
|
||||
.aiStatPrice(AI_STAT_PRICE)
|
||||
.aiStatPackSize(AI_STAT_PACK_SIZE)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ShopStatusResponse buyWalletSlot(Long memberId) {
|
||||
int slots = memberMapper.getExtraWalletSlots(memberId);
|
||||
if (slots >= MAX_EXTRA_SLOTS) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "SHOP_WALLET_SLOT_MAX");
|
||||
}
|
||||
long points = pointService(memberId);
|
||||
if (points < WALLET_SLOT_PRICE) {
|
||||
throw new ResponseStatusException(HttpStatus.PAYMENT_REQUIRED, "SHOP_NOT_ENOUGH_POINTS");
|
||||
}
|
||||
pointMapper.insertHistory(memberId, -WALLET_SLOT_PRICE, REASON_SHOP_WALLET_SLOT);
|
||||
pointMapper.addPoints(memberId, -WALLET_SLOT_PRICE);
|
||||
memberMapper.incrementExtraWalletSlots(memberId);
|
||||
return getStatus(memberId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ShopStatusResponse buyAiStatPack(Long memberId) {
|
||||
long points = pointService(memberId);
|
||||
if (points < AI_STAT_PRICE) {
|
||||
throw new ResponseStatusException(HttpStatus.PAYMENT_REQUIRED, "SHOP_NOT_ENOUGH_POINTS");
|
||||
}
|
||||
pointMapper.insertHistory(memberId, -AI_STAT_PRICE, REASON_SHOP_AI_STAT);
|
||||
pointMapper.addPoints(memberId, -AI_STAT_PRICE);
|
||||
memberMapper.addAiStatCredits(memberId, AI_STAT_PACK_SIZE);
|
||||
return getStatus(memberId);
|
||||
}
|
||||
|
||||
private long pointService(Long memberId) {
|
||||
Long p = pointMapper.getPoints(memberId);
|
||||
return p == null ? 0L : p;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.sb.web.admin.controller;
|
||||
|
||||
import com.sb.web.board.dto.BoardSettingRequest;
|
||||
import com.sb.web.board.dto.BoardSettingResponse;
|
||||
import com.sb.web.board.dto.TagCategoryRequest;
|
||||
import com.sb.web.board.dto.TagCategoryResponse;
|
||||
import com.sb.web.board.dto.TagRequest;
|
||||
@@ -68,16 +66,4 @@ public class AdminTagController {
|
||||
tagService.deleteTag(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/* ===== 게시판 설정 (사용할 태그 카테고리) ===== */
|
||||
|
||||
@GetMapping("/board-setting")
|
||||
public BoardSettingResponse boardSetting() {
|
||||
return tagService.getBoardSetting();
|
||||
}
|
||||
|
||||
@PutMapping("/board-setting")
|
||||
public BoardSettingResponse updateBoardSetting(@RequestBody BoardSettingRequest req) {
|
||||
return tagService.setBoardSetting(req.getTagCategoryId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final com.sb.web.admin.service.AppSettingService appSettingService;
|
||||
private final com.sb.web.point.PointService pointService;
|
||||
|
||||
/** 회원가입 허용 여부 (비보호) — 프론트가 가입 진입 전에 차단 표시용 */
|
||||
@GetMapping("/signup-enabled")
|
||||
@@ -68,6 +69,12 @@ public class AuthController {
|
||||
return authService.googleLogin(req.getIdToken(), req.isRememberMe());
|
||||
}
|
||||
|
||||
/** 애플 로그인/가입 (비보호) — Sign in with Apple identity token 검증 후 세션 발급 */
|
||||
@PostMapping("/apple")
|
||||
public LoginResponse appleLogin(@Valid @RequestBody com.sb.web.auth.dto.AppleLoginRequest req) {
|
||||
return authService.appleLogin(req.getIdentityToken(), req.getName(), req.isRememberMe());
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout(HttpServletRequest request) {
|
||||
authService.logout(AuthInterceptor.resolveToken(request));
|
||||
@@ -79,6 +86,34 @@ public class AuthController {
|
||||
return current;
|
||||
}
|
||||
|
||||
/** 회원 탈퇴 — 본인 계정과 모든 데이터 삭제 */
|
||||
@DeleteMapping("/me")
|
||||
public ResponseEntity<Void> withdraw(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current,
|
||||
HttpServletRequest request) {
|
||||
authService.withdraw(current.getId(), AuthInterceptor.resolveToken(request));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 현재 사용자 활동 포인트 (최신값 — 게시판 작성으로 수시 변동) */
|
||||
@GetMapping("/points")
|
||||
public java.util.Map<String, Long> points(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return java.util.Map.of("points", pointService.getPoints(current.getId()));
|
||||
}
|
||||
|
||||
/** 포인트 적립/차감 내역 — year+month 지정 시 해당 월 전건, 미지정 시 최근 100건 */
|
||||
@GetMapping("/point-history")
|
||||
public java.util.List<com.sb.web.point.PointHistoryResponse> pointHistory(
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
if (year != null && month != null) {
|
||||
return pointService.getHistory(current.getId(), year, month);
|
||||
}
|
||||
return pointService.getHistory(current.getId());
|
||||
}
|
||||
|
||||
@PutMapping("/password")
|
||||
public ResponseEntity<Void> changePassword(
|
||||
@Valid @RequestBody PasswordChangeRequest req,
|
||||
@@ -96,6 +131,12 @@ public class AuthController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 현재 회원 전체 프로필 (아바타·포인트 포함) — 재로그인 없이 최신값 동기화 */
|
||||
@GetMapping("/profile")
|
||||
public MemberResponse profile(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return authService.getProfile(current.getId());
|
||||
}
|
||||
|
||||
/** 가입정보(이름/이메일) 변경 */
|
||||
@PutMapping("/profile")
|
||||
public MemberResponse updateProfile(
|
||||
@@ -104,4 +145,12 @@ public class AuthController {
|
||||
HttpServletRequest request) {
|
||||
return authService.updateProfile(current.getId(), AuthInterceptor.resolveToken(request), req);
|
||||
}
|
||||
|
||||
/** 프로필 사진(사용자 지정) 변경/해제 — image 가 비면 해제(구글 사진으로 폴백) */
|
||||
@PutMapping("/profile-image")
|
||||
public MemberResponse updateProfileImage(
|
||||
@RequestBody com.sb.web.auth.dto.ProfileImageRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return authService.updateProfileImage(current.getId(), req.getImage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,15 @@ public class Member implements Serializable {
|
||||
private String provider; // LOCAL / NAVER / GOOGLE
|
||||
private String providerId; // 소셜 제공자 측 고유 ID
|
||||
private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지)
|
||||
private String appleId; // 애플 sub (계정 연결용 — 구글과 동일)
|
||||
private String googlePicture; // 구글 아바타 URL (로그인 시 동기화)
|
||||
private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
|
||||
private String role; // USER / ADMIN
|
||||
private String plan; // FREE / PREMIUM (멤버십)
|
||||
private LocalDateTime planExpiresAt; // 유료 멤버십 만료일 (NULL=만료없음/무료)
|
||||
private String planProduct; // 구독 상품 ID (premium_monthly 등)
|
||||
private Boolean planAutoRenew; // 자동 갱신 여부 (해지 시 false)
|
||||
private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상)
|
||||
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sb.web.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 애플 로그인 요청 — 프론트(Sign in with Apple)에서 받은 identity token(JWT).
|
||||
* 이름은 애플이 <b>최초 인증 시에만</b> 클라이언트에 주므로, 신규 가입 시 채우도록 함께 전달받는다.
|
||||
*/
|
||||
@Data
|
||||
public class AppleLoginRequest {
|
||||
|
||||
@NotBlank(message = "토큰이 없습니다.")
|
||||
private String identityToken;
|
||||
|
||||
private String name; // 최초 로그인 시에만 제공될 수 있음(이후 null)
|
||||
|
||||
private boolean rememberMe;
|
||||
}
|
||||
@@ -18,6 +18,12 @@ public class MemberResponse {
|
||||
private String provider;
|
||||
private String role;
|
||||
private String plan; // FREE / PREMIUM
|
||||
private java.time.LocalDateTime planExpiresAt; // 유료 멤버십 만료일
|
||||
private String planProduct; // 구독 상품 ID
|
||||
private Boolean planAutoRenew; // 자동 갱신 여부
|
||||
private Long points; // 활동 포인트
|
||||
private String googlePicture; // 구글 아바타 URL
|
||||
private String profileImage; // 사용자 지정 프로필 이미지(data URL)
|
||||
|
||||
public static MemberResponse from(Member m) {
|
||||
return MemberResponse.builder()
|
||||
@@ -28,6 +34,12 @@ public class MemberResponse {
|
||||
.provider(m.getProvider())
|
||||
.role(m.getRole())
|
||||
.plan(m.getPlan())
|
||||
.planExpiresAt(m.getPlanExpiresAt())
|
||||
.planProduct(m.getPlanProduct())
|
||||
.planAutoRenew(m.getPlanAutoRenew())
|
||||
.points(m.getPoints())
|
||||
.googlePicture(m.getGooglePicture())
|
||||
.profileImage(m.getProfileImage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sb.web.auth.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 프로필 사진(사용자 지정) 변경 요청. image 가 null/빈 값이면 해제(구글 사진으로 폴백).
|
||||
*/
|
||||
@Data
|
||||
public class ProfileImageRequest {
|
||||
|
||||
/** data URL(base64 이미지). null 이면 사용자 지정 사진 해제. */
|
||||
private String image;
|
||||
}
|
||||
@@ -21,6 +21,9 @@ public interface AuthSessionMapper {
|
||||
/** 프로필 변경 시 백업 세션의 표시 이름 동기화 */
|
||||
int updateName(@Param("token") String token, @Param("name") String name);
|
||||
|
||||
/** 멤버십 변경 시 백업 세션의 plan 동기화 */
|
||||
int updatePlan(@Param("token") String token, @Param("plan") String plan);
|
||||
|
||||
int delete(@Param("token") String token);
|
||||
|
||||
int deleteExpired(@Param("now") LocalDateTime now);
|
||||
|
||||
@@ -31,6 +31,15 @@ public interface MemberMapper {
|
||||
/** 기존 계정에 구글 sub 연결 (provider 유지). */
|
||||
int linkGoogle(@Param("id") Long id, @Param("googleId") String googleId);
|
||||
|
||||
/** 애플 sub 로 회원 조회 (연결된 LOCAL 계정 + 순수 애플 계정 모두). */
|
||||
Member findByAppleId(@Param("appleId") String appleId);
|
||||
|
||||
/** 애플 로그인 시 연결 대상이 될 같은 이메일의 기존 계정(LOCAL 우선, 애플 미연결만). */
|
||||
Member findByEmailForAppleLink(@Param("email") String email);
|
||||
|
||||
/** 기존 계정에 애플 sub 연결 (provider 유지). */
|
||||
int linkApple(@Param("id") Long id, @Param("appleId") String appleId);
|
||||
|
||||
int countByLoginId(@Param("loginId") String loginId);
|
||||
|
||||
int insert(Member member);
|
||||
@@ -40,11 +49,35 @@ public interface MemberMapper {
|
||||
/** 프로필(이름/이메일) 변경 */
|
||||
int updateProfile(@Param("id") Long id, @Param("name") String name, @Param("email") String email);
|
||||
|
||||
/** 구글 로그인 시 구글 아바타 URL 동기화 */
|
||||
int updateGooglePicture(@Param("id") Long id, @Param("googlePicture") String googlePicture);
|
||||
|
||||
/** 사용자 지정 프로필 이미지 설정/해제(null = 해제 → 구글 사진으로 폴백) */
|
||||
int updateProfileImage(@Param("id") Long id, @Param("profileImage") String profileImage);
|
||||
|
||||
int updateRole(@Param("id") Long id, @Param("role") String role);
|
||||
|
||||
int updatePlan(@Param("id") Long id, @Param("plan") String plan);
|
||||
|
||||
/** 결제로 멤버십 부여/갱신 (plan + 만료일 + 구독상품 + 자동갱신) */
|
||||
int updateMembership(@Param("id") Long id, @Param("plan") String plan,
|
||||
@Param("expiresAt") java.time.LocalDateTime expiresAt,
|
||||
@Param("product") String product, @Param("autoRenew") boolean autoRenew);
|
||||
|
||||
/** 구독 해지/재개 (자동 갱신 플래그) */
|
||||
int updateAutoRenew(@Param("id") Long id, @Param("autoRenew") boolean autoRenew);
|
||||
|
||||
/** 만료된 유료회원을 FREE 로 강등 (스케줄러). 강등된 건수 반환 */
|
||||
int downgradeExpired();
|
||||
|
||||
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
||||
|
||||
int deleteById(@Param("id") Long id);
|
||||
|
||||
// ── 포인트 상점 ──────────────────────────────────────────────
|
||||
int getExtraWalletSlots(@Param("id") Long id);
|
||||
int getAiStatCredits(@Param("id") Long id);
|
||||
int incrementExtraWalletSlots(@Param("id") Long id);
|
||||
int addAiStatCredits(@Param("id") Long id, @Param("amount") int amount);
|
||||
int decrementAiStatCredit(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.sb.web.auth.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 회원 탈퇴 시 가계부(BackupMapper) 외의 사용자 데이터 정리 — 게시판/포인트/결제/세션.
|
||||
*/
|
||||
@Mapper
|
||||
public interface WithdrawMapper {
|
||||
|
||||
// 게시판 — 내가 누른 표
|
||||
int deletePostVotesByMember(@Param("memberId") Long memberId);
|
||||
int deleteCommentVotesByMember(@Param("memberId") Long memberId);
|
||||
|
||||
// 게시판 — 내 글에 달린 것들 (남이 단 댓글/표 포함)
|
||||
int deleteCommentVotesOnMyPosts(@Param("memberId") Long memberId);
|
||||
int deletePostVotesOnMyPosts(@Param("memberId") Long memberId);
|
||||
int deletePostTagsOnMyPosts(@Param("memberId") Long memberId);
|
||||
int deleteCommentsOnMyPosts(@Param("memberId") Long memberId);
|
||||
|
||||
// 게시판 — 내 댓글(남의 글에 단 것)과 그 표
|
||||
int deleteCommentVotesOnMyComments(@Param("memberId") Long memberId);
|
||||
int deleteCommentsByAuthor(@Param("memberId") Long memberId);
|
||||
|
||||
// 게시판 — 내 글
|
||||
int deletePostsByAuthor(@Param("memberId") Long memberId);
|
||||
int deleteBoardImagesByMember(@Param("memberId") Long memberId);
|
||||
|
||||
// 신고 / 포인트 / 결제 / 세션
|
||||
int deleteReportsByMember(@Param("memberId") Long memberId);
|
||||
int deletePointHistory(@Param("memberId") Long memberId);
|
||||
int deleteIapPurchases(@Param("memberId") Long memberId);
|
||||
int deleteAuthSessions(@Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -38,11 +38,20 @@ public class AuthService {
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final com.sb.web.admin.service.AppSettingService appSettingService;
|
||||
private final com.sb.web.auth.mapper.AuthSessionMapper authSessionMapper;
|
||||
private final com.sb.web.auth.mapper.WithdrawMapper withdrawMapper;
|
||||
private final com.sb.web.account.mapper.BackupMapper backupMapper;
|
||||
|
||||
/** 구글 OAuth 클라이언트 ID (ID 토큰 aud 검증용). 미설정 시 구글 로그인 비활성. */
|
||||
@org.springframework.beans.factory.annotation.Value("${app.google-client-id:}")
|
||||
private String googleClientId;
|
||||
|
||||
/** 애플 로그인 aud(=iOS 앱 번들 ID). iOS 번들ID는 kr.sblog.slimbudget.app (안드로이드 패키지와 별개). */
|
||||
@org.springframework.beans.factory.annotation.Value("${app.apple-client-id:kr.sblog.slimbudget.app}")
|
||||
private String appleClientId;
|
||||
|
||||
/** 애플 ID 토큰 검증기(JWKS 캐시 포함). 최초 사용 시 lazy 초기화. */
|
||||
private volatile com.nimbusds.jwt.proc.ConfigurableJWTProcessor<com.nimbusds.jose.proc.SecurityContext> appleJwtProcessor;
|
||||
|
||||
private static final String SESSION_PREFIX = "session:";
|
||||
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
|
||||
private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인(로그인 상태 유지)
|
||||
@@ -186,6 +195,7 @@ public class AuthService {
|
||||
boolean emailVerified = "true".equals(String.valueOf(info.get("email_verified")));
|
||||
String name = info.get("name") != null ? String.valueOf(info.get("name"))
|
||||
: (email != null ? email.split("@")[0] : "사용자");
|
||||
String picture = info.get("picture") != null ? String.valueOf(info.get("picture")) : null;
|
||||
|
||||
// 1) 구글 sub 로 이미 연결/가입된 계정 조회
|
||||
Member member = memberMapper.findByGoogleId(sub);
|
||||
@@ -216,6 +226,7 @@ public class AuthService {
|
||||
.provider("GOOGLE")
|
||||
.providerId(sub)
|
||||
.googleId(sub)
|
||||
.googlePicture(picture)
|
||||
.role("USER")
|
||||
.status("ACTIVE")
|
||||
.build();
|
||||
@@ -225,6 +236,11 @@ public class AuthService {
|
||||
if (!"ACTIVE".equals(member.getStatus())) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
||||
}
|
||||
// 구글 아바타가 바뀌었으면 동기화(신규 가입은 이미 반영됨 → 변경 없을 때 불필요한 UPDATE 생략)
|
||||
if (picture != null && !picture.equals(member.getGooglePicture())) {
|
||||
memberMapper.updateGooglePicture(member.getId(), picture);
|
||||
member.setGooglePicture(picture);
|
||||
}
|
||||
return issueSession(member, rememberMe);
|
||||
}
|
||||
|
||||
@@ -232,6 +248,110 @@ public class AuthService {
|
||||
return googleClientId == null ? "" : googleClientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 애플 로그인. Sign in with Apple identity token(JWT)을 애플 공개키(JWKS)로 검증한 뒤,
|
||||
* 구글과 동일한 규칙으로 계정을 조회/연결/생성한다.
|
||||
*/
|
||||
public LoginResponse appleLogin(String identityToken, String providedName, boolean rememberMe) {
|
||||
if (identityToken == null || identityToken.isBlank()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "토큰이 없습니다.");
|
||||
}
|
||||
com.nimbusds.jwt.JWTClaimsSet claims;
|
||||
try {
|
||||
claims = verifyAppleToken(identityToken);
|
||||
} catch (Exception e) {
|
||||
log.warn("[apple] token verify failed: {}", e.toString());
|
||||
throw new ApiException(HttpStatus.UNAUTHORIZED, "애플 인증에 실패했습니다.");
|
||||
}
|
||||
// 발급자·대상(aud=번들 ID)·만료 검증
|
||||
java.util.Date now = new java.util.Date();
|
||||
if (claims.getExpirationTime() == null || claims.getExpirationTime().before(now)
|
||||
|| !"https://appleid.apple.com".equals(claims.getIssuer())
|
||||
|| claims.getAudience() == null || !claims.getAudience().contains(appleClientId)) {
|
||||
throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 애플 토큰입니다.");
|
||||
}
|
||||
String sub = claims.getSubject();
|
||||
if (sub == null || sub.isBlank()) {
|
||||
throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 애플 토큰입니다.");
|
||||
}
|
||||
String email;
|
||||
boolean emailVerified;
|
||||
try {
|
||||
email = claims.getStringClaim("email");
|
||||
Object ev = claims.getClaim("email_verified"); // 문자열("true")/불리언 모두 대응
|
||||
emailVerified = ev != null && "true".equals(String.valueOf(ev));
|
||||
} catch (Exception e) {
|
||||
email = null;
|
||||
emailVerified = false;
|
||||
}
|
||||
String name = (providedName != null && !providedName.isBlank()) ? providedName
|
||||
: (email != null ? email.split("@")[0] : "사용자");
|
||||
|
||||
// 1) 애플 sub 로 이미 연결/가입된 계정 조회
|
||||
Member member = memberMapper.findByAppleId(sub);
|
||||
|
||||
// 2) 없으면 같은 (검증된)이메일의 기존 계정에 애플 연결 — 중복 계정/데이터 분리 방지
|
||||
if (member == null && email != null && !email.isBlank() && emailVerified) {
|
||||
Member existing = memberMapper.findByEmailForAppleLink(email);
|
||||
if (existing != null) {
|
||||
if (!"ACTIVE".equals(existing.getStatus())) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
||||
}
|
||||
memberMapper.linkApple(existing.getId(), sub);
|
||||
existing.setAppleId(sub);
|
||||
member = existing;
|
||||
log.info("[apple] linked to existing member id={} provider={} email={}",
|
||||
member.getId(), member.getProvider(), email);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 그래도 없으면 신규 애플 계정 생성(최초 로그인 = 가입). 가입 제한 시 차단.
|
||||
if (member == null) {
|
||||
if (!appSettingService.isSignupEnabled()) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "현재 회원가입이 제한되어 있습니다.");
|
||||
}
|
||||
member = Member.builder()
|
||||
.name(name)
|
||||
.email(email)
|
||||
.provider("APPLE")
|
||||
.providerId(sub)
|
||||
.appleId(sub)
|
||||
.role("USER")
|
||||
.status("ACTIVE")
|
||||
.build();
|
||||
memberMapper.insert(member);
|
||||
log.info("[apple] new member id={} email={}", member.getId(), email);
|
||||
}
|
||||
if (!"ACTIVE".equals(member.getStatus())) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
||||
}
|
||||
return issueSession(member, rememberMe);
|
||||
}
|
||||
|
||||
/** 애플 identity token 을 애플 JWKS(공개키)로 서명 검증하고 클레임을 반환. */
|
||||
private com.nimbusds.jwt.JWTClaimsSet verifyAppleToken(String idToken) throws Exception {
|
||||
com.nimbusds.jwt.proc.ConfigurableJWTProcessor<com.nimbusds.jose.proc.SecurityContext> proc = appleJwtProcessor;
|
||||
if (proc == null) {
|
||||
synchronized (this) {
|
||||
if (appleJwtProcessor == null) {
|
||||
com.nimbusds.jose.jwk.source.JWKSource<com.nimbusds.jose.proc.SecurityContext> keySource =
|
||||
com.nimbusds.jose.jwk.source.JWKSourceBuilder
|
||||
.create(new java.net.URL("https://appleid.apple.com/auth/keys"))
|
||||
.retrying(true)
|
||||
.build();
|
||||
com.nimbusds.jwt.proc.DefaultJWTProcessor<com.nimbusds.jose.proc.SecurityContext> p =
|
||||
new com.nimbusds.jwt.proc.DefaultJWTProcessor<>();
|
||||
p.setJWSKeySelector(new com.nimbusds.jose.proc.JWSVerificationKeySelector<>(
|
||||
com.nimbusds.jose.JWSAlgorithm.RS256, keySource));
|
||||
appleJwtProcessor = p;
|
||||
}
|
||||
proc = appleJwtProcessor;
|
||||
}
|
||||
}
|
||||
// 서명 검증(iss/aud/exp 는 호출부에서 확인)
|
||||
return proc.process(idToken, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
|
||||
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
|
||||
@@ -282,6 +402,51 @@ public class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴 — 사용자가 소유한 모든 데이터를 삭제하고 회원을 제거한다.
|
||||
* (게시판 글/댓글/추천 · 가계부 전체 · 포인트 · 결제기록 · 세션 → 회원)
|
||||
*/
|
||||
@Transactional
|
||||
public void withdraw(Long memberId, String token) {
|
||||
Member member = memberMapper.findById(memberId);
|
||||
if (member == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||
}
|
||||
// 1) 게시판 — 표/댓글/글/이미지 (참조 순서 고려)
|
||||
withdrawMapper.deletePostVotesByMember(memberId);
|
||||
withdrawMapper.deleteCommentVotesByMember(memberId);
|
||||
withdrawMapper.deleteCommentVotesOnMyPosts(memberId);
|
||||
withdrawMapper.deleteCommentVotesOnMyComments(memberId);
|
||||
withdrawMapper.deleteCommentsOnMyPosts(memberId);
|
||||
withdrawMapper.deleteCommentsByAuthor(memberId);
|
||||
withdrawMapper.deletePostVotesOnMyPosts(memberId);
|
||||
withdrawMapper.deletePostTagsOnMyPosts(memberId);
|
||||
withdrawMapper.deletePostsByAuthor(memberId);
|
||||
withdrawMapper.deleteBoardImagesByMember(memberId);
|
||||
// 2) 가계부(계정) 데이터 (복구 삭제와 동일 순서)
|
||||
backupMapper.deleteEntryTags(memberId);
|
||||
backupMapper.deleteEntries(memberId);
|
||||
backupMapper.deleteRecurrings(memberId);
|
||||
backupMapper.deleteBudgets(memberId);
|
||||
backupMapper.deleteBudgetIncome(memberId);
|
||||
backupMapper.deleteQuickEntries(memberId);
|
||||
backupMapper.deleteInvestTrades(memberId);
|
||||
backupMapper.deleteInvestHoldings(memberId);
|
||||
backupMapper.deleteCategories(memberId);
|
||||
backupMapper.deleteTags(memberId);
|
||||
backupMapper.deleteWallets(memberId);
|
||||
// 3) 신고 / 포인트 / 결제 / 세션
|
||||
withdrawMapper.deleteReportsByMember(memberId);
|
||||
withdrawMapper.deletePointHistory(memberId);
|
||||
withdrawMapper.deleteIapPurchases(memberId);
|
||||
withdrawMapper.deleteAuthSessions(memberId);
|
||||
// 4) 회원 삭제
|
||||
memberMapper.deleteById(memberId);
|
||||
// 5) 현재 세션(Redis) 정리
|
||||
logout(token);
|
||||
log.info("[withdraw] member {} ({}) 및 데이터 전체 삭제", memberId, member.getLoginId());
|
||||
}
|
||||
|
||||
/** 만료된 백업 세션 정리 (매일 새벽 4시) */
|
||||
@org.springframework.scheduling.annotation.Scheduled(cron = "0 0 4 * * *")
|
||||
public void cleanupExpiredSessions() {
|
||||
@@ -312,6 +477,15 @@ public class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 현재 회원 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화용 */
|
||||
public MemberResponse getProfile(Long memberId) {
|
||||
Member member = memberMapper.findById(memberId);
|
||||
if (member == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||
}
|
||||
return MemberResponse.from(member);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가입정보(이름/이메일) 변경. 비밀번호 인증을 통과한 화면에서 호출된다.
|
||||
* 변경 후 세션(Redis + DB 백업)의 표시 이름도 동기화한다.
|
||||
@@ -335,6 +509,31 @@ public class AuthService {
|
||||
return MemberResponse.from(member);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로필 사진(사용자 지정) 설정/해제. null/빈 값이면 해제 → 구글 사진으로 폴백.
|
||||
* data URL(base64 이미지)만 허용하고 과도한 크기는 거절한다.
|
||||
*/
|
||||
@Transactional
|
||||
public MemberResponse updateProfileImage(Long memberId, String image) {
|
||||
Member member = memberMapper.findById(memberId);
|
||||
if (member == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||
}
|
||||
String value = (image == null || image.isBlank()) ? null : image.trim();
|
||||
if (value != null) {
|
||||
if (!value.startsWith("data:image/")) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 형식이 올바르지 않습니다.");
|
||||
}
|
||||
if (value.length() > 700_000) { // 약 500KB 이미지 상한 (base64 약 33% 팽창 고려)
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 용량이 너무 큽니다. 더 작은 사진을 사용하세요.");
|
||||
}
|
||||
}
|
||||
memberMapper.updateProfileImage(memberId, value);
|
||||
member.setProfileImage(value);
|
||||
log.info("[profile] image {} for {}", value == null ? "cleared" : "updated", member.getLoginId());
|
||||
return MemberResponse.from(member);
|
||||
}
|
||||
|
||||
/** 프로필 이름 변경 시 활성 세션(Redis + DB 백업)의 표시 이름을 맞춘다. */
|
||||
private void syncSessionName(String token, String name) {
|
||||
if (token == null || token.isBlank()) {
|
||||
@@ -361,6 +560,32 @@ public class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 결제/멤버십 변경 시 활성 세션(Redis + DB 백업)의 plan 을 즉시 맞춘다 (재로그인 없이 유료 기능 개방). */
|
||||
public void syncSessionPlan(String token, String plan) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String key = SESSION_PREFIX + token;
|
||||
try {
|
||||
Object cached = redisTemplate.opsForValue().get(key);
|
||||
if (cached instanceof SessionUser u) {
|
||||
u.setPlan(plan);
|
||||
Long ttl = redisTemplate.getExpire(key, java.util.concurrent.TimeUnit.SECONDS);
|
||||
Duration remain = (ttl != null && ttl > 0)
|
||||
? Duration.ofSeconds(ttl)
|
||||
: (u.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
|
||||
redisTemplate.opsForValue().set(key, u, remain);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[billing] Redis 세션 plan 동기화 실패(무시): {}", e.toString());
|
||||
}
|
||||
try {
|
||||
authSessionMapper.updatePlan(token, plan);
|
||||
} catch (Exception ignore) {
|
||||
// DB 백업 동기화 실패해도 다음 로그인 시 갱신됨
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.sb.web.billing;
|
||||
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import com.sb.web.billing.dto.MembershipResponse;
|
||||
import com.sb.web.billing.dto.ProductResponse;
|
||||
import com.sb.web.billing.dto.SubscriptionResponse;
|
||||
import com.sb.web.billing.dto.VerifyRequest;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 인앱 결제 API. (/api/billing/** — 로그인 필요)
|
||||
* GET /products 구독 상품 목록
|
||||
* POST /google/verify Google Play 구매 검증 → 멤버십 부여
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/billing")
|
||||
@RequiredArgsConstructor
|
||||
public class BillingController {
|
||||
|
||||
private final BillingService billingService;
|
||||
|
||||
@GetMapping("/products")
|
||||
public List<ProductResponse> products() {
|
||||
return billingService.products();
|
||||
}
|
||||
|
||||
@PostMapping("/google/verify")
|
||||
public MembershipResponse verifyGoogle(
|
||||
@Valid @RequestBody VerifyRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current,
|
||||
HttpServletRequest request) {
|
||||
return billingService.verifyGoogle(current.getId(), AuthInterceptor.resolveToken(request), req);
|
||||
}
|
||||
|
||||
/** 현재 구독 현황 (플랜·만료일·다음 결제일·자동갱신) */
|
||||
@GetMapping("/subscription")
|
||||
public SubscriptionResponse subscription(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return billingService.getSubscription(current.getId());
|
||||
}
|
||||
|
||||
/** 구독 해지 (자동 갱신 중단, 만료일까지 이용) */
|
||||
@PostMapping("/cancel")
|
||||
public SubscriptionResponse cancel(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return billingService.cancel(current.getId());
|
||||
}
|
||||
|
||||
/** 구독 재개 (만료 전 자동 갱신 재개) */
|
||||
@PostMapping("/resume")
|
||||
public SubscriptionResponse resume(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return billingService.resume(current.getId());
|
||||
}
|
||||
|
||||
/** 구매 복원 (기기 변경·재설치 시 최근 결제 기준 멤버십 복구) */
|
||||
@PostMapping("/restore")
|
||||
public SubscriptionResponse restore(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current,
|
||||
HttpServletRequest request) {
|
||||
return billingService.restore(current.getId(), AuthInterceptor.resolveToken(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Play 실시간 개발자 알림(RTDN) 수신 — Pub/Sub push (비인증).
|
||||
* 스캐폴드: 수신/로깅만. 실연동 시 메시지 검증 + 구독상태(갱신/해지/환불) 동기화 구현.
|
||||
*/
|
||||
@PostMapping("/google/rtdn")
|
||||
public org.springframework.http.ResponseEntity<Void> rtdn(@RequestBody(required = false) java.util.Map<String, Object> body) {
|
||||
billingService.handleRtdn(body);
|
||||
return org.springframework.http.ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.sb.web.billing;
|
||||
|
||||
import com.sb.web.auth.domain.Member;
|
||||
import com.sb.web.auth.mapper.MemberMapper;
|
||||
import com.sb.web.auth.service.AuthService;
|
||||
import com.sb.web.billing.domain.IapPurchase;
|
||||
import com.sb.web.billing.dto.MembershipResponse;
|
||||
import com.sb.web.billing.dto.ProductResponse;
|
||||
import com.sb.web.billing.dto.SubscriptionResponse;
|
||||
import com.sb.web.billing.dto.VerifyRequest;
|
||||
import com.sb.web.billing.mapper.IapPurchaseMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 인앱 결제(Google Play) 처리 — 상품 목록, 구매 검증 후 멤버십 부여.
|
||||
* 스캐폴드: billing.test-mode=true 면 'test-' 토큰을 실제 검증 없이 승인한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BillingService {
|
||||
|
||||
private final MemberMapper memberMapper;
|
||||
private final IapPurchaseMapper purchaseMapper;
|
||||
private final GooglePlayVerifier googlePlayVerifier;
|
||||
private final AuthService authService;
|
||||
|
||||
/** 실연동 전 기본 true — 'test-' 토큰으로 결제 흐름 테스트 가능 */
|
||||
@Value("${billing.test-mode:true}")
|
||||
private boolean testMode;
|
||||
|
||||
/** 구독 상품 카탈로그 (가격은 표시용 — 실제 금액은 Play Console 기준) */
|
||||
private static final Map<String, ProductResponse> CATALOG = new LinkedHashMap<>();
|
||||
static {
|
||||
CATALOG.put("premium_monthly", new ProductResponse("premium_monthly", "프리미엄 월간", 1, 2900));
|
||||
CATALOG.put("premium_yearly", new ProductResponse("premium_yearly", "프리미엄 연간", 12, 29000));
|
||||
}
|
||||
|
||||
public List<ProductResponse> products() {
|
||||
return List.copyOf(CATALOG.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Play 구매 검증 후 멤버십(PREMIUM) 부여/갱신.
|
||||
* 같은 구매 토큰은 한 번만 처리(멱등). 세션 plan 도 즉시 동기화.
|
||||
*/
|
||||
@Transactional
|
||||
public MembershipResponse verifyGoogle(Long memberId, String sessionToken, VerifyRequest req) {
|
||||
ProductResponse product = CATALOG.get(req.getProductId());
|
||||
if (product == null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "알 수 없는 상품입니다.");
|
||||
}
|
||||
String token = req.getPurchaseToken();
|
||||
|
||||
// 이미 처리된 결제면 현재 상태 반환 (중복 지급 방지)
|
||||
IapPurchase existing = purchaseMapper.findByToken(token);
|
||||
if (existing != null) {
|
||||
return MembershipResponse.of(mustFind(memberId));
|
||||
}
|
||||
|
||||
boolean test = testMode || token.startsWith("test-");
|
||||
String platform;
|
||||
if (test) {
|
||||
platform = "TEST";
|
||||
} else {
|
||||
platform = "GOOGLE_PLAY";
|
||||
if (!googlePlayVerifier.verify(req.getProductId(), token)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "결제 검증에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 만료일이 미래면 그 시점부터 연장, 아니면 지금부터
|
||||
Member member = mustFind(memberId);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime base = (member.getPlanExpiresAt() != null && member.getPlanExpiresAt().isAfter(now))
|
||||
? member.getPlanExpiresAt() : now;
|
||||
LocalDateTime expires = base.plusMonths(product.getMonths());
|
||||
|
||||
memberMapper.updateMembership(memberId, "PREMIUM", expires, req.getProductId(), true);
|
||||
purchaseMapper.insert(IapPurchase.builder()
|
||||
.memberId(memberId).platform(platform).productId(req.getProductId())
|
||||
.purchaseToken(token).orderId(req.getOrderId())
|
||||
.status("VERIFIED").expiresAt(expires).build());
|
||||
authService.syncSessionPlan(sessionToken, "PREMIUM");
|
||||
|
||||
log.info("[billing] PREMIUM granted member={} product={} platform={} expires={}",
|
||||
memberId, req.getProductId(), platform, expires);
|
||||
|
||||
member.setPlan("PREMIUM");
|
||||
member.setPlanExpiresAt(expires);
|
||||
return MembershipResponse.of(member);
|
||||
}
|
||||
|
||||
/** 현재 구독 현황 */
|
||||
public SubscriptionResponse getSubscription(Long memberId) {
|
||||
return toSubscription(mustFind(memberId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 구매 복원 — 기기 변경·재설치 시 최근 결제 기록 기준으로 멤버십을 복구한다.
|
||||
* (실연동에서는 Google Play 의 활성 구매를 조회해 동기화하도록 교체)
|
||||
*/
|
||||
@Transactional
|
||||
public SubscriptionResponse restore(Long memberId, String sessionToken) {
|
||||
Member member = mustFind(memberId);
|
||||
IapPurchase latest = purchaseMapper.findLatestByMember(memberId);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
boolean stillValid = latest != null && latest.getExpiresAt() != null && latest.getExpiresAt().isAfter(now);
|
||||
boolean needsRestore = !"PREMIUM".equals(member.getPlan())
|
||||
|| member.getPlanExpiresAt() == null
|
||||
|| member.getPlanExpiresAt().isBefore(latest != null && latest.getExpiresAt() != null ? latest.getExpiresAt() : now);
|
||||
if (stillValid && needsRestore) {
|
||||
memberMapper.updateMembership(memberId, "PREMIUM", latest.getExpiresAt(), latest.getProductId(), true);
|
||||
authService.syncSessionPlan(sessionToken, "PREMIUM");
|
||||
member.setPlan("PREMIUM");
|
||||
member.setPlanExpiresAt(latest.getExpiresAt());
|
||||
member.setPlanProduct(latest.getProductId());
|
||||
member.setPlanAutoRenew(true);
|
||||
log.info("[billing] 구매 복원 member={} expires={}", memberId, latest.getExpiresAt());
|
||||
}
|
||||
return toSubscription(member);
|
||||
}
|
||||
|
||||
/** 구독 해지 — 자동 갱신만 끈다. 이용은 만료일까지 유지(이후 무료 전환). */
|
||||
@Transactional
|
||||
public SubscriptionResponse cancel(Long memberId) {
|
||||
Member m = mustFind(memberId);
|
||||
if (!"PREMIUM".equals(m.getPlan())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "구독 중이 아닙니다.");
|
||||
}
|
||||
memberMapper.updateAutoRenew(memberId, false);
|
||||
m.setPlanAutoRenew(false);
|
||||
log.info("[billing] subscription canceled member={} (이용 만료일까지 유지)", memberId);
|
||||
return toSubscription(m);
|
||||
}
|
||||
|
||||
/** 구독 재개 — 만료 전이면 자동 갱신을 다시 켠다. */
|
||||
@Transactional
|
||||
public SubscriptionResponse resume(Long memberId) {
|
||||
Member m = mustFind(memberId);
|
||||
if (!"PREMIUM".equals(m.getPlan())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "구독 중이 아닙니다.");
|
||||
}
|
||||
memberMapper.updateAutoRenew(memberId, true);
|
||||
m.setPlanAutoRenew(true);
|
||||
return toSubscription(m);
|
||||
}
|
||||
|
||||
private SubscriptionResponse toSubscription(Member m) {
|
||||
boolean premium = "PREMIUM".equals(m.getPlan());
|
||||
boolean autoRenew = premium && Boolean.TRUE.equals(m.getPlanAutoRenew());
|
||||
ProductResponse p = m.getPlanProduct() == null ? null : CATALOG.get(m.getPlanProduct());
|
||||
String label = p != null ? p.getLabel() : (premium ? "프리미엄" : null);
|
||||
return SubscriptionResponse.builder()
|
||||
.premium(premium)
|
||||
.planProduct(m.getPlanProduct())
|
||||
.planLabel(label)
|
||||
.expiresAt(m.getPlanExpiresAt())
|
||||
.autoRenew(autoRenew)
|
||||
.nextBillingAt(autoRenew ? m.getPlanExpiresAt() : null)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* RTDN(실시간 개발자 알림) 처리 — 스캐폴드.
|
||||
* 실연동: message.data(base64 JSON) 디코드 → subscriptionNotification 의 purchaseToken 으로
|
||||
* Play Developer API 재조회 후 멤버십 동기화(갱신/해지/만료/환불).
|
||||
*/
|
||||
public void handleRtdn(Map<String, Object> body) {
|
||||
try {
|
||||
Object message = body == null ? null : body.get("message");
|
||||
String data = null;
|
||||
if (message instanceof Map<?, ?> m && m.get("data") != null) {
|
||||
data = new String(java.util.Base64.getDecoder().decode(String.valueOf(m.get("data"))),
|
||||
java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
log.info("[billing][rtdn] 수신 (스캐폴드 — 미처리): {}", data != null ? data : body);
|
||||
} catch (Exception e) {
|
||||
log.warn("[billing][rtdn] 파싱 실패: {}", e.toString());
|
||||
}
|
||||
// TODO(실연동): 구독 상태 변경(SUBSCRIPTION_RENEWED/CANCELED/EXPIRED/REVOKED) 반영
|
||||
}
|
||||
|
||||
private Member mustFind(Long memberId) {
|
||||
Member m = memberMapper.findById(memberId);
|
||||
if (m == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||
}
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sb.web.billing;
|
||||
|
||||
/**
|
||||
* Google Play 구매 토큰 검증 추상화.
|
||||
* 실연동(androidpublisher API + 서비스 계정) 구현체로 교체하면 된다.
|
||||
*/
|
||||
public interface GooglePlayVerifier {
|
||||
|
||||
/** 구매 토큰이 유효하면 true. 검증 미구성/실패 시 예외 또는 false. */
|
||||
boolean verify(String productId, String purchaseToken);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.sb.web.billing;
|
||||
|
||||
import com.sb.web.auth.mapper.MemberMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 만료된 유료 멤버십 자동 강등. 매시간 실행.
|
||||
* (만료일이 NULL 인 관리자 영구 부여는 강등되지 않음)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PremiumExpiryScheduler {
|
||||
|
||||
private final MemberMapper memberMapper;
|
||||
|
||||
@Scheduled(cron = "0 5 * * * *") // 매시 5분
|
||||
public void downgradeExpired() {
|
||||
int n = memberMapper.downgradeExpired();
|
||||
if (n > 0) {
|
||||
log.info("[billing] 만료 멤버십 강등: {}건", n);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sb.web.billing;
|
||||
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 실연동 전 스텁. 실제 Google Play 검증은 아직 구성되지 않았다.
|
||||
* (테스트 모드에서는 BillingService 가 이 검증을 건너뛴다.)
|
||||
*/
|
||||
@Component
|
||||
public class StubGooglePlayVerifier implements GooglePlayVerifier {
|
||||
|
||||
@Override
|
||||
public boolean verify(String productId, String purchaseToken) {
|
||||
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"구글 결제 검증이 아직 구성되지 않았습니다. (테스트 모드로만 결제 가능)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.sb.web.billing.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 인앱 결제 기록 (iap_purchase).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IapPurchase {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String platform; // GOOGLE_PLAY / TEST
|
||||
private String productId;
|
||||
private String purchaseToken;
|
||||
private String orderId;
|
||||
private String status; // VERIFIED / FAILED
|
||||
private LocalDateTime expiresAt;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.billing.dto;
|
||||
|
||||
import com.sb.web.auth.domain.Member;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 결제 후 멤버십 상태.
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class MembershipResponse {
|
||||
|
||||
private String plan; // FREE / PREMIUM
|
||||
private LocalDateTime planExpiresAt; // 만료일 (NULL=만료없음)
|
||||
private boolean premium;
|
||||
|
||||
public static MembershipResponse of(Member m) {
|
||||
boolean premium = "PREMIUM".equals(m.getPlan());
|
||||
return new MembershipResponse(m.getPlan(), m.getPlanExpiresAt(), premium);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.sb.web.billing.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 구독 상품 (업그레이드 화면 표시용).
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class ProductResponse {
|
||||
|
||||
private String id; // Google Play 상품 ID
|
||||
private String label; // 표시 이름
|
||||
private int months; // 부여 개월수
|
||||
private int priceKrw; // 표시용 가격(원) — 실제 결제 금액은 Play Console 기준(스캐폴드 표시용)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.sb.web.billing.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 정기결제(구독) 현황 — 계정정보 화면 표시용.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class SubscriptionResponse {
|
||||
|
||||
private boolean premium; // 현재 유료 여부
|
||||
private String planProduct; // 구독 상품 ID
|
||||
private String planLabel; // 상품 표시 이름 (프리미엄 월간 등)
|
||||
private LocalDateTime expiresAt; // 이용 만료일
|
||||
private boolean autoRenew; // 자동 갱신 여부
|
||||
private LocalDateTime nextBillingAt; // 다음 결제일 (자동 갱신 시 = 만료일, 해지 시 null)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.sb.web.billing.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Google Play 구매 검증 요청.
|
||||
*/
|
||||
@Data
|
||||
public class VerifyRequest {
|
||||
|
||||
@NotBlank
|
||||
private String productId;
|
||||
|
||||
/** Play 구매 토큰 (테스트는 'test-' 로 시작) */
|
||||
@NotBlank
|
||||
private String purchaseToken;
|
||||
|
||||
private String orderId;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.sb.web.billing.mapper;
|
||||
|
||||
import com.sb.web.billing.domain.IapPurchase;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 인앱 결제 기록 매퍼.
|
||||
*/
|
||||
@Mapper
|
||||
public interface IapPurchaseMapper {
|
||||
|
||||
int insert(IapPurchase purchase);
|
||||
|
||||
/** 구매 토큰으로 기존 처리 여부 조회 (중복 결제 방지) */
|
||||
IapPurchase findByToken(@Param("purchaseToken") String purchaseToken);
|
||||
|
||||
/** 회원의 가장 최근(만료일 먼) 결제 — 구매 복원용 */
|
||||
IapPurchase findLatestByMember(@Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -8,7 +8,11 @@ import com.sb.web.board.dto.PageResponse;
|
||||
import com.sb.web.board.dto.PostDetail;
|
||||
import com.sb.web.board.dto.PostRequest;
|
||||
import com.sb.web.board.dto.PostSummary;
|
||||
import com.sb.web.board.dto.Recommender;
|
||||
import com.sb.web.board.dto.ReportedItem;
|
||||
import com.sb.web.board.dto.TagCategoryResponse;
|
||||
import com.sb.web.board.dto.VoteRequest;
|
||||
import com.sb.web.board.dto.VoteResponse;
|
||||
import com.sb.web.board.service.BoardService;
|
||||
import com.sb.web.board.service.TagService;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -50,6 +54,24 @@ public class BoardController {
|
||||
return boardService.list(page, size, category, tag, keyword, searchType);
|
||||
}
|
||||
|
||||
/** 내 글 모아보기 */
|
||||
@GetMapping("/my/posts")
|
||||
public PageResponse<PostSummary> myPosts(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return boardService.myPosts(current.getId(), page, size);
|
||||
}
|
||||
|
||||
/** 내 댓글 모아보기 */
|
||||
@GetMapping("/my/comments")
|
||||
public PageResponse<com.sb.web.board.dto.MyCommentResponse> myComments(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return boardService.myComments(current.getId(), page, size);
|
||||
}
|
||||
|
||||
@GetMapping("/posts/{id}")
|
||||
public PostDetail get(
|
||||
@PathVariable Long id,
|
||||
@@ -117,15 +139,23 @@ public class BoardController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 목록 태그 필터 — 게시판 지정 시 그 게시판에 매핑된 그룹의 태그만 */
|
||||
@GetMapping("/tags")
|
||||
public List<String> tags() {
|
||||
return boardService.allTags();
|
||||
public List<String> tags(@RequestParam(required = false) String category) {
|
||||
if (category == null || category.isBlank()) {
|
||||
return boardService.allTags();
|
||||
}
|
||||
return tagService.listWritableGroups(category).stream()
|
||||
.flatMap(g -> g.getTags().stream())
|
||||
.map(com.sb.web.board.dto.TagResponse::getName)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 글 작성 시 선택 가능한 태그 (게시판 설정 카테고리로 제한) */
|
||||
/** 글 작성 시 선택 가능한 태그 (해당 게시판에 매핑된 그룹만) */
|
||||
@GetMapping("/tag-groups")
|
||||
public List<TagCategoryResponse> tagGroups() {
|
||||
return tagService.listWritableGroups();
|
||||
public List<TagCategoryResponse> tagGroups(@RequestParam(required = false) String category) {
|
||||
return tagService.listWritableGroups(category);
|
||||
}
|
||||
|
||||
@PostMapping("/posts/{id}/comments")
|
||||
@@ -143,4 +173,71 @@ public class BoardController {
|
||||
boardService.deleteComment(commentId, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 게시글 추천/비추천 (토글) */
|
||||
@PostMapping("/posts/{id}/vote")
|
||||
public VoteResponse votePost(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody VoteRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return boardService.votePost(id, req.getType(), current);
|
||||
}
|
||||
|
||||
/** 게시글 추천(👍)한 사람 목록 */
|
||||
@GetMapping("/posts/{id}/recommenders")
|
||||
public List<Recommender> recommenders(@PathVariable Long id) {
|
||||
return boardService.recommenders(id);
|
||||
}
|
||||
|
||||
/** 댓글 추천/비추천 (토글) */
|
||||
@PostMapping("/comments/{commentId}/vote")
|
||||
public VoteResponse voteComment(
|
||||
@PathVariable Long commentId,
|
||||
@Valid @RequestBody VoteRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return boardService.voteComment(commentId, req.getType(), current);
|
||||
}
|
||||
|
||||
/** 게시글 신고 (누적 5건 시 블라인드) */
|
||||
@PostMapping("/posts/{id}/report")
|
||||
public ResponseEntity<Void> reportPost(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.reportPost(id, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 댓글 신고 (누적 5건 시 블라인드) */
|
||||
@PostMapping("/comments/{commentId}/report")
|
||||
public ResponseEntity<Void> reportComment(
|
||||
@PathVariable Long commentId,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.reportComment(commentId, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 댓글 블라인드 해제 (관리자) */
|
||||
@PostMapping("/comments/{commentId}/unblock")
|
||||
public ResponseEntity<Void> unblockComment(
|
||||
@PathVariable Long commentId,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.unblockComment(commentId, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 신고된 글/댓글 목록 (관리자) — 대상별 신고 수 집계 */
|
||||
@GetMapping("/reports")
|
||||
public List<ReportedItem> reports(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return boardService.listReportedItems(current);
|
||||
}
|
||||
|
||||
/** 신고 무시 (관리자) — 블라인드 없이 신고 기록만 초기화 */
|
||||
@PostMapping("/reports/dismiss")
|
||||
public ResponseEntity<Void> dismissReport(
|
||||
@RequestBody Map<String, String> body,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.dismissReports(body.get("targetType"), Long.valueOf(body.get("targetId")), current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ public class Comment implements Serializable {
|
||||
private Long postId;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함)
|
||||
private String authorProfileImage; // 조회 시 member JOIN (저장 안 함)
|
||||
private String content;
|
||||
private Boolean blocked; // 신고 누적 블라인드
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ public class Post implements Serializable {
|
||||
private String content;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함)
|
||||
private String authorProfileImage; // 조회 시 member JOIN (저장 안 함)
|
||||
private Integer viewCount;
|
||||
private Boolean blocked;
|
||||
private String blockReason;
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 게시판 설정 변경 요청 — 사용할 태그 카테고리(null 이면 제한 없음).
|
||||
*/
|
||||
@Data
|
||||
public class BoardSettingRequest {
|
||||
private Long tagCategoryId;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 게시판 설정 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class BoardSettingResponse {
|
||||
private Long tagCategoryId;
|
||||
}
|
||||
@@ -16,16 +16,28 @@ public class CommentResponse {
|
||||
private Long id;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private String authorGooglePicture;
|
||||
private String authorProfileImage;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private Integer upCount; // 추천 수
|
||||
private Integer downCount; // 비추천 수
|
||||
private String myVote; // 현재 사용자의 투표: UP / DOWN / null
|
||||
private Boolean hot; // 인기 댓글(추천 50+ & 1일 이내) 상단 노출
|
||||
private Boolean blocked; // 신고 누적 블라인드(관리자만 본문 열람)
|
||||
|
||||
public static CommentResponse from(Comment c) {
|
||||
return CommentResponse.builder()
|
||||
.id(c.getId())
|
||||
.authorId(c.getAuthorId())
|
||||
.authorName(c.getAuthorName())
|
||||
.authorGooglePicture(c.getAuthorGooglePicture())
|
||||
.authorProfileImage(c.getAuthorProfileImage())
|
||||
.content(c.getContent())
|
||||
.blocked(Boolean.TRUE.equals(c.getBlocked()))
|
||||
.createdAt(c.getCreatedAt())
|
||||
.upCount(0)
|
||||
.downCount(0)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 한 게시글의 댓글별 추천/비추천 집계 + 현재 사용자 투표 (N+1 방지용 일괄 조회 결과).
|
||||
*/
|
||||
@Data
|
||||
public class CommentVoteSummary {
|
||||
|
||||
private Long commentId;
|
||||
private int upCount;
|
||||
private int downCount;
|
||||
private String myVote; // UP / DOWN / null
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 내 댓글 모아보기 항목 (어느 글의 댓글인지 함께).
|
||||
*/
|
||||
@Data
|
||||
public class MyCommentResponse {
|
||||
|
||||
private Long id;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private Long postId;
|
||||
private String postTitle;
|
||||
private String category;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ public class PostDetail {
|
||||
private String content;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private String authorGooglePicture;
|
||||
private String authorProfileImage;
|
||||
private Integer viewCount;
|
||||
private Boolean blocked;
|
||||
private String blockReason;
|
||||
@@ -28,6 +30,10 @@ public class PostDetail {
|
||||
private LocalDateTime updatedAt;
|
||||
private List<String> tags;
|
||||
private List<CommentResponse> comments;
|
||||
private Integer upCount; // 추천 수
|
||||
private Integer downCount; // 비추천 수
|
||||
private String myVote; // 현재 사용자의 투표: UP / DOWN / null
|
||||
private List<Recommender> recommenders; // 추천(👍)한 사람 목록 (아바타 표기용)
|
||||
|
||||
public static PostDetail of(Post p, List<String> tags, List<CommentResponse> comments) {
|
||||
return PostDetail.builder()
|
||||
@@ -36,6 +42,8 @@ public class PostDetail {
|
||||
.content(p.getContent())
|
||||
.authorId(p.getAuthorId())
|
||||
.authorName(p.getAuthorName())
|
||||
.authorGooglePicture(p.getAuthorGooglePicture())
|
||||
.authorProfileImage(p.getAuthorProfileImage())
|
||||
.viewCount(p.getViewCount())
|
||||
.blocked(Boolean.TRUE.equals(p.getBlocked()))
|
||||
.blockReason(p.getBlockReason())
|
||||
|
||||
@@ -13,9 +13,16 @@ public class PostSummary {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String category; // 내 글 모아보기 등 교차 게시판 링크용
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private String authorGooglePicture;
|
||||
private String authorProfileImage;
|
||||
private Integer viewCount;
|
||||
private Integer commentCount;
|
||||
private Integer upCount; // 추천 수
|
||||
private Integer downCount; // 비추천 수
|
||||
private Boolean hot; // 인기 글(추천 50+ & 1일 이내) 상단 노출
|
||||
private Boolean blocked;
|
||||
private Boolean notice;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 추천(👍)한 사람 (본문 하단 아바타·이름 표기용).
|
||||
*/
|
||||
@Data
|
||||
public class Recommender {
|
||||
|
||||
private Long memberId;
|
||||
private String name;
|
||||
private String googlePicture;
|
||||
private String profileImage;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 신고 누적 항목 (관리자 신고 관리 화면). 대상별(글/댓글)로 신고 수를 집계.
|
||||
*/
|
||||
@Data
|
||||
public class ReportedItem {
|
||||
|
||||
private String targetType; // POST / COMMENT
|
||||
private Long targetId; // 대상(글/댓글) id
|
||||
private Long postId; // 링크용 글 id (POST=자기 자신, COMMENT=상위 글)
|
||||
private String category; // 상위 글 게시판(community/saving/tips)
|
||||
private String title; // 글 제목 (댓글이면 상위 글 제목)
|
||||
private String content; // 본문/댓글 내용 (앞부분 요약)
|
||||
private Long authorId; // 작성자 id
|
||||
private String authorName; // 작성자 표시명
|
||||
private Boolean blocked; // 이미 블라인드 되었는지
|
||||
private String blockReason; // 블라인드 사유 (글만 존재; 스팸 감지 / 신고 누적 등)
|
||||
private Integer reportCount; // 누적 신고 수
|
||||
private LocalDateTime lastReportedAt; // 마지막 신고 시각
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 태그 카테고리 생성/수정 요청.
|
||||
*/
|
||||
@@ -15,4 +17,7 @@ public class TagCategoryRequest {
|
||||
private String name;
|
||||
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 이 그룹을 사용할 게시판 (community/saving/tips). 비어있으면 전체 게시판. */
|
||||
private List<String> boards;
|
||||
}
|
||||
|
||||
@@ -17,13 +17,15 @@ public class TagCategoryResponse {
|
||||
private String name;
|
||||
private Integer sortOrder;
|
||||
private List<TagResponse> tags;
|
||||
private List<String> boards; // 이 그룹을 사용할 게시판 (비어있으면 전체)
|
||||
|
||||
public static TagCategoryResponse of(TagCategory c, List<TagResponse> tags) {
|
||||
public static TagCategoryResponse of(TagCategory c, List<TagResponse> tags, List<String> boards) {
|
||||
return TagCategoryResponse.builder()
|
||||
.id(c.getId())
|
||||
.name(c.getName())
|
||||
.sortOrder(c.getSortOrder())
|
||||
.tags(tags)
|
||||
.boards(boards)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 추천/비추천 요청. 같은 표를 다시 보내면 취소(토글), 반대면 전환.
|
||||
*/
|
||||
@Data
|
||||
public class VoteRequest {
|
||||
|
||||
@Pattern(regexp = "UP|DOWN", message = "투표는 UP 또는 DOWN 이어야 합니다.")
|
||||
private String type;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 추천/비추천 후 최신 집계 + 현재 사용자 상태.
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class VoteResponse {
|
||||
|
||||
private int upCount;
|
||||
private int downCount;
|
||||
private String myVote; // UP / DOWN / null
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface BoardSettingMapper {
|
||||
|
||||
/** 게시판에서 사용할 태그 카테고리 ID (없으면 null) */
|
||||
Long findTagCategoryId();
|
||||
|
||||
int updateTagCategoryId(@Param("categoryId") Long categoryId);
|
||||
}
|
||||
@@ -18,4 +18,13 @@ public interface CommentMapper {
|
||||
int delete(@Param("id") Long id);
|
||||
|
||||
int deleteByPostId(@Param("postId") Long postId);
|
||||
|
||||
/** 신고 누적 블라인드 설정 */
|
||||
int updateBlocked(@Param("id") Long id, @Param("blocked") boolean blocked);
|
||||
|
||||
/** 내 댓글 모아보기 (글 제목·카테고리 포함) */
|
||||
java.util.List<com.sb.web.board.dto.MyCommentResponse> findMyPage(
|
||||
@Param("memberId") Long memberId, @Param("offset") int offset, @Param("size") int size);
|
||||
|
||||
long countMyComments(@Param("memberId") Long memberId);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,18 @@ public interface PostMapper {
|
||||
@Param("keyword") String keyword,
|
||||
@Param("searchType") String searchType);
|
||||
|
||||
/** 인기 글(추천 minUp 이상 & hours 시간 이내) 상단 노출용 — 추천 많은 순 limit 건 */
|
||||
List<PostSummary> findHotPosts(@Param("category") String category,
|
||||
@Param("hours") int hours,
|
||||
@Param("minUp") int minUp,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/** 내 글 모아보기 */
|
||||
List<PostSummary> findMyPage(@Param("memberId") Long memberId,
|
||||
@Param("offset") int offset, @Param("size") int size);
|
||||
|
||||
long countMyPosts(@Param("memberId") Long memberId);
|
||||
|
||||
Post findById(@Param("id") Long id);
|
||||
|
||||
int insert(Post post);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import com.sb.web.board.dto.ReportedItem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 신고(report) 매퍼 — 글/댓글 신고 누적 집계.
|
||||
*/
|
||||
@Mapper
|
||||
public interface ReportMapper {
|
||||
|
||||
/** 신고 추가 (회원당 대상별 1회 — 중복은 무시) */
|
||||
int insertIgnore(@Param("targetType") String targetType,
|
||||
@Param("targetId") Long targetId,
|
||||
@Param("memberId") Long memberId,
|
||||
@Param("reason") String reason);
|
||||
|
||||
/** 신고된 글/댓글 목록 (대상별 집계 — 관리자 화면) */
|
||||
List<ReportedItem> listReported();
|
||||
|
||||
int countByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
|
||||
|
||||
int deleteByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
|
||||
|
||||
int deleteByMember(@Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 태그 카테고리(그룹) ↔ 게시판 매핑 (tag_category_board).
|
||||
*/
|
||||
@Mapper
|
||||
public interface TagCategoryBoardMapper {
|
||||
|
||||
/** 카테고리가 노출될 게시판 목록 */
|
||||
List<String> findBoards(@Param("categoryId") Long categoryId);
|
||||
|
||||
void deleteByCategory(@Param("categoryId") Long categoryId);
|
||||
|
||||
void insert(@Param("categoryId") Long categoryId, @Param("board") String board);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import com.sb.web.board.dto.CommentVoteSummary;
|
||||
import com.sb.web.board.dto.Recommender;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 게시글/댓글 추천·비추천(post_vote / comment_vote) 매퍼.
|
||||
*/
|
||||
@Mapper
|
||||
public interface VoteMapper {
|
||||
|
||||
// ===== 게시글 =====
|
||||
/** 현재 사용자의 게시글 투표(UP/DOWN) 또는 null */
|
||||
String findPostVote(@Param("postId") Long postId, @Param("memberId") Long memberId);
|
||||
|
||||
/** 투표 등록/변경 (INSERT … ON DUPLICATE KEY UPDATE) */
|
||||
int upsertPostVote(@Param("postId") Long postId, @Param("memberId") Long memberId, @Param("type") String type);
|
||||
|
||||
int deletePostVote(@Param("postId") Long postId, @Param("memberId") Long memberId);
|
||||
|
||||
int countPostVotes(@Param("postId") Long postId, @Param("type") String type);
|
||||
|
||||
/** 추천(👍)한 사람 목록 (최신순) */
|
||||
List<Recommender> findPostRecommenders(@Param("postId") Long postId);
|
||||
|
||||
int deletePostVotesByPost(@Param("postId") Long postId);
|
||||
|
||||
// ===== 댓글 =====
|
||||
String findCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId);
|
||||
|
||||
int upsertCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId, @Param("type") String type);
|
||||
|
||||
int deleteCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId);
|
||||
|
||||
int countCommentVotes(@Param("commentId") Long commentId, @Param("type") String type);
|
||||
|
||||
/** 한 게시글의 댓글별 집계 + 현재 사용자 투표 (일괄) */
|
||||
List<CommentVoteSummary> findCommentVoteSummary(@Param("postId") Long postId, @Param("memberId") Long memberId);
|
||||
|
||||
int deleteCommentVotesByComment(@Param("commentId") Long commentId);
|
||||
|
||||
/** 게시글 삭제 시 그 글의 모든 댓글 투표 정리 */
|
||||
int deleteCommentVotesByPost(@Param("postId") Long postId);
|
||||
}
|
||||
@@ -5,14 +5,21 @@ import com.sb.web.board.domain.Comment;
|
||||
import com.sb.web.board.domain.Post;
|
||||
import com.sb.web.board.dto.CommentRequest;
|
||||
import com.sb.web.board.dto.CommentResponse;
|
||||
import com.sb.web.board.dto.CommentVoteSummary;
|
||||
import com.sb.web.board.dto.PageResponse;
|
||||
import com.sb.web.board.dto.PostDetail;
|
||||
import com.sb.web.board.dto.PostRequest;
|
||||
import com.sb.web.board.dto.PostSummary;
|
||||
import com.sb.web.board.dto.Recommender;
|
||||
import com.sb.web.board.dto.ReportedItem;
|
||||
import com.sb.web.board.dto.VoteResponse;
|
||||
import com.sb.web.board.mapper.CommentMapper;
|
||||
import com.sb.web.board.mapper.PostMapper;
|
||||
import com.sb.web.board.mapper.ReportMapper;
|
||||
import com.sb.web.board.mapper.TagMapper;
|
||||
import com.sb.web.board.mapper.VoteMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import com.sb.web.point.PointService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -20,7 +27,9 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
@@ -33,10 +42,24 @@ public class BoardService {
|
||||
private final PostMapper postMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final CommentMapper commentMapper;
|
||||
private final VoteMapper voteMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final PointService pointService;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
/** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */
|
||||
private static final Duration VIEW_DEDUP_TTL = Duration.ofHours(24);
|
||||
private static final String UP = "UP";
|
||||
private static final String DOWN = "DOWN";
|
||||
private static final String T_POST = "POST";
|
||||
private static final String T_COMMENT = "COMMENT";
|
||||
/** 신고 누적 블라인드 임계 */
|
||||
private static final int REPORT_BLIND_THRESHOLD = 5;
|
||||
|
||||
// 인기 글/댓글: 등록 1일 이내 + 추천 50건 이상이면 상단에 최대 3건 노출
|
||||
private static final int HOT_WITHIN_HOURS = 24;
|
||||
private static final int HOT_MIN_UP = 50;
|
||||
private static final int HOT_MAX = 3;
|
||||
|
||||
/* ===================== 게시글 ===================== */
|
||||
|
||||
@@ -50,9 +73,39 @@ public class BoardService {
|
||||
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
|
||||
List<PostSummary> content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st);
|
||||
long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st);
|
||||
|
||||
// 1페이지·검색 없을 때만 인기 글을 최상단에 노출 (중복 제거)
|
||||
if (p == 1 && blankToNull(tag) == null && blankToNull(keyword) == null) {
|
||||
List<PostSummary> hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX);
|
||||
if (!hot.isEmpty()) {
|
||||
java.util.Set<Long> hotIds = new java.util.HashSet<>();
|
||||
hot.forEach(h -> { h.setHot(true); hotIds.add(h.getId()); });
|
||||
List<PostSummary> merged = new java.util.ArrayList<>(hot);
|
||||
for (PostSummary ps : content) {
|
||||
if (!hotIds.contains(ps.getId())) merged.add(ps);
|
||||
}
|
||||
content = merged;
|
||||
}
|
||||
}
|
||||
return PageResponse.of(content, p, s, total);
|
||||
}
|
||||
|
||||
/** 내 글 모아보기 */
|
||||
public PageResponse<PostSummary> myPosts(Long memberId, int page, int size) {
|
||||
int p = Math.max(page, 1);
|
||||
int s = Math.min(Math.max(size, 1), 100);
|
||||
List<PostSummary> content = postMapper.findMyPage(memberId, (p - 1) * s, s);
|
||||
return PageResponse.of(content, p, s, postMapper.countMyPosts(memberId));
|
||||
}
|
||||
|
||||
/** 내 댓글 모아보기 */
|
||||
public PageResponse<com.sb.web.board.dto.MyCommentResponse> myComments(Long memberId, int page, int size) {
|
||||
int p = Math.max(page, 1);
|
||||
int s = Math.min(Math.max(size, 1), 100);
|
||||
var content = commentMapper.findMyPage(memberId, (p - 1) * s, s);
|
||||
return PageResponse.of(content, p, s, commentMapper.countMyComments(memberId));
|
||||
}
|
||||
|
||||
private String normalizeSearchType(String t) {
|
||||
return ("content".equals(t) || "comment".equals(t)) ? t : "title";
|
||||
}
|
||||
@@ -80,7 +133,7 @@ public class BoardService {
|
||||
postMapper.increaseViewCount(id);
|
||||
post.setViewCount(post.getViewCount() + 1);
|
||||
}
|
||||
return assemble(post);
|
||||
return assemble(post, viewer);
|
||||
}
|
||||
|
||||
/** 게시글 열람 제한/해제 (관리자 전용) */
|
||||
@@ -89,6 +142,22 @@ public class BoardService {
|
||||
assertAdmin(user);
|
||||
mustFindPost(id);
|
||||
postMapper.updateBlocked(id, blocked, blocked ? reason : null);
|
||||
// 해제 시 신고 기록 초기화 → 다음 신고 1건에 곧바로 재블라인드되지 않게
|
||||
if (!blocked) {
|
||||
reportMapper.deleteByTarget(T_POST, id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 댓글 블라인드 해제 (관리자 전용) — 신고 기록 초기화 */
|
||||
@Transactional
|
||||
public void unblockComment(Long commentId, SessionUser user) {
|
||||
assertAdmin(user);
|
||||
Comment comment = commentMapper.findById(commentId);
|
||||
if (comment == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||
}
|
||||
commentMapper.updateBlocked(commentId, false);
|
||||
reportMapper.deleteByTarget(T_COMMENT, commentId);
|
||||
}
|
||||
|
||||
/** 게시글 공지 설정/해제 (관리자 전용) — 목록 최상단 고정 */
|
||||
@@ -122,7 +191,12 @@ public class BoardService {
|
||||
.build();
|
||||
postMapper.insert(post);
|
||||
applyTags(post.getId(), req.getTagIds());
|
||||
return assemble(mustFindPost(post.getId()));
|
||||
if (pointService.isSpam(req.getContent())) {
|
||||
postMapper.updateBlocked(post.getId(), true, "스팸 키워드 감지");
|
||||
} else {
|
||||
pointService.awardBoardWrite(author.getId(), req.getContent());
|
||||
}
|
||||
return assemble(mustFindPost(post.getId()), author);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -144,13 +218,17 @@ public class BoardService {
|
||||
|
||||
tagMapper.deletePostTagsByPostId(id);
|
||||
applyTags(id, req.getTagIds());
|
||||
return assemble(mustFindPost(id));
|
||||
return assemble(mustFindPost(id), user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, SessionUser user) {
|
||||
Post post = mustFindPost(id);
|
||||
assertCanModify(post.getAuthorId(), user);
|
||||
// 투표 정리(댓글 투표는 댓글 삭제 전에 — 서브쿼리가 comment 참조)
|
||||
voteMapper.deleteCommentVotesByPost(id);
|
||||
voteMapper.deletePostVotesByPost(id);
|
||||
reportMapper.deleteByTarget(T_POST, id);
|
||||
tagMapper.deletePostTagsByPostId(id);
|
||||
commentMapper.deleteByPostId(id);
|
||||
postMapper.delete(id);
|
||||
@@ -171,6 +249,11 @@ public class BoardService {
|
||||
.content(req.getContent())
|
||||
.build();
|
||||
commentMapper.insert(comment);
|
||||
if (pointService.isSpam(req.getContent())) {
|
||||
commentMapper.updateBlocked(comment.getId(), true);
|
||||
} else {
|
||||
pointService.awardBoardWrite(author.getId(), req.getContent());
|
||||
}
|
||||
return CommentResponse.from(commentMapper.findById(comment.getId()));
|
||||
}
|
||||
|
||||
@@ -181,9 +264,116 @@ public class BoardService {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||
}
|
||||
assertCanModify(comment.getAuthorId(), user);
|
||||
voteMapper.deleteCommentVotesByComment(commentId);
|
||||
reportMapper.deleteByTarget(T_COMMENT, commentId);
|
||||
commentMapper.delete(commentId);
|
||||
}
|
||||
|
||||
/* ===================== 신고 ===================== */
|
||||
|
||||
/** 게시글 신고 — 회원당 1회, 누적 5건이면 블라인드(관리자만 열람) */
|
||||
@Transactional
|
||||
public void reportPost(Long postId, SessionUser user) {
|
||||
Post post = mustFindPost(postId);
|
||||
if (post.getAuthorId().equals(user.getId())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 글은 신고할 수 없습니다.");
|
||||
}
|
||||
reportMapper.insertIgnore(T_POST, postId, user.getId(), null);
|
||||
if (!Boolean.TRUE.equals(post.getBlocked())
|
||||
&& reportMapper.countByTarget(T_POST, postId) >= REPORT_BLIND_THRESHOLD) {
|
||||
postMapper.updateBlocked(postId, true, "신고 누적으로 블라인드되었습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/** 댓글 신고 — 회원당 1회, 누적 5건이면 블라인드(관리자만 열람) */
|
||||
@Transactional
|
||||
public void reportComment(Long commentId, SessionUser user) {
|
||||
Comment comment = commentMapper.findById(commentId);
|
||||
if (comment == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||
}
|
||||
if (comment.getAuthorId().equals(user.getId())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 댓글은 신고할 수 없습니다.");
|
||||
}
|
||||
reportMapper.insertIgnore(T_COMMENT, commentId, user.getId(), null);
|
||||
if (!Boolean.TRUE.equals(comment.getBlocked())
|
||||
&& reportMapper.countByTarget(T_COMMENT, commentId) >= REPORT_BLIND_THRESHOLD) {
|
||||
commentMapper.updateBlocked(commentId, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** 신고된 글/댓글 목록 (관리자 전용) — 대상별 신고 수 집계 */
|
||||
@Transactional(readOnly = true)
|
||||
public List<ReportedItem> listReportedItems(SessionUser user) {
|
||||
assertAdmin(user);
|
||||
return reportMapper.listReported();
|
||||
}
|
||||
|
||||
/** 신고 무시 (관리자 전용) — 블라인드 없이 신고 기록만 초기화 */
|
||||
@Transactional
|
||||
public void dismissReports(String targetType, Long targetId, SessionUser user) {
|
||||
assertAdmin(user);
|
||||
if (!T_POST.equals(targetType) && !T_COMMENT.equals(targetType)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "잘못된 신고 대상 유형입니다.");
|
||||
}
|
||||
reportMapper.deleteByTarget(targetType, targetId);
|
||||
}
|
||||
|
||||
/* ===================== 추천/비추천 ===================== */
|
||||
|
||||
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
|
||||
@Transactional
|
||||
public VoteResponse votePost(Long postId, String type, SessionUser user) {
|
||||
Post post = mustFindPost(postId);
|
||||
if (post.getAuthorId().equals(user.getId())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 글에는 추천/비추천할 수 없습니다.");
|
||||
}
|
||||
Long memberId = user.getId();
|
||||
String existing = voteMapper.findPostVote(postId, memberId);
|
||||
boolean cancel = type.equals(existing);
|
||||
if (cancel) {
|
||||
voteMapper.deletePostVote(postId, memberId);
|
||||
} else {
|
||||
voteMapper.upsertPostVote(postId, memberId, type);
|
||||
}
|
||||
pointService.applyVotePoints(post.getAuthorId(), memberId, existing, type);
|
||||
return new VoteResponse(
|
||||
voteMapper.countPostVotes(postId, UP),
|
||||
voteMapper.countPostVotes(postId, DOWN),
|
||||
cancel ? null : type);
|
||||
}
|
||||
|
||||
/** 댓글 추천/비추천 토글 */
|
||||
@Transactional
|
||||
public VoteResponse voteComment(Long commentId, String type, SessionUser user) {
|
||||
Comment comment = commentMapper.findById(commentId);
|
||||
if (comment == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||
}
|
||||
if (comment.getAuthorId().equals(user.getId())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 댓글에는 추천/비추천할 수 없습니다.");
|
||||
}
|
||||
Long memberId = user.getId();
|
||||
String existing = voteMapper.findCommentVote(commentId, memberId);
|
||||
boolean cancel = type.equals(existing);
|
||||
if (cancel) {
|
||||
voteMapper.deleteCommentVote(commentId, memberId);
|
||||
} else {
|
||||
voteMapper.upsertCommentVote(commentId, memberId, type);
|
||||
}
|
||||
pointService.applyVotePoints(comment.getAuthorId(), memberId, existing, type);
|
||||
return new VoteResponse(
|
||||
voteMapper.countCommentVotes(commentId, UP),
|
||||
voteMapper.countCommentVotes(commentId, DOWN),
|
||||
cancel ? null : type);
|
||||
}
|
||||
|
||||
/** 추천(👍)한 사람 목록 (본문 하단 표기/모달용) */
|
||||
public List<Recommender> recommenders(Long postId) {
|
||||
mustFindPost(postId);
|
||||
return voteMapper.findPostRecommenders(postId);
|
||||
}
|
||||
|
||||
/* ===================== 태그 ===================== */
|
||||
|
||||
public List<String> allTags() {
|
||||
@@ -192,11 +382,62 @@ public class BoardService {
|
||||
|
||||
/* ===================== 내부 ===================== */
|
||||
|
||||
private PostDetail assemble(Post post) {
|
||||
List<String> tags = tagMapper.findNamesByPostId(post.getId());
|
||||
List<CommentResponse> comments = commentMapper.findByPostId(post.getId())
|
||||
.stream().map(CommentResponse::from).toList();
|
||||
return PostDetail.of(post, tags, comments);
|
||||
private PostDetail assemble(Post post, SessionUser viewer) {
|
||||
Long postId = post.getId();
|
||||
Long viewerId = viewer == null ? null : viewer.getId();
|
||||
List<String> tags = tagMapper.findNamesByPostId(postId);
|
||||
|
||||
// 댓글별 추천/비추천 집계를 한 번에 조회(N+1 방지)
|
||||
Map<Long, CommentVoteSummary> voteByComment = new HashMap<>();
|
||||
for (CommentVoteSummary s : voteMapper.findCommentVoteSummary(postId, viewerId)) {
|
||||
voteByComment.put(s.getCommentId(), s);
|
||||
}
|
||||
List<CommentResponse> comments = new java.util.ArrayList<>(commentMapper.findByPostId(postId).stream().map(c -> {
|
||||
CommentResponse cr = CommentResponse.from(c);
|
||||
CommentVoteSummary s = voteByComment.get(c.getId());
|
||||
if (s != null) {
|
||||
cr.setUpCount(s.getUpCount());
|
||||
cr.setDownCount(s.getDownCount());
|
||||
cr.setMyVote(s.getMyVote());
|
||||
}
|
||||
return cr;
|
||||
}).toList());
|
||||
// 신고 누적 블라인드 댓글: 관리자가 아니면 본문 숨김
|
||||
boolean viewerAdmin = isAdmin(viewer);
|
||||
if (!viewerAdmin) {
|
||||
comments.forEach(cr -> {
|
||||
if (Boolean.TRUE.equals(cr.getBlocked())) cr.setContent(null);
|
||||
});
|
||||
}
|
||||
comments = orderWithHot(comments);
|
||||
|
||||
PostDetail detail = PostDetail.of(post, tags, comments);
|
||||
detail.setUpCount(voteMapper.countPostVotes(postId, UP));
|
||||
detail.setDownCount(voteMapper.countPostVotes(postId, DOWN));
|
||||
detail.setMyVote(viewerId == null ? null : voteMapper.findPostVote(postId, viewerId));
|
||||
detail.setRecommenders(voteMapper.findPostRecommenders(postId));
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** 인기 댓글(추천 50+ & 1일 이내) 최대 3건을 추천 많은 순으로 상단에 올리고 hot 표시 */
|
||||
private List<CommentResponse> orderWithHot(List<CommentResponse> comments) {
|
||||
java.time.LocalDateTime cutoff = java.time.LocalDateTime.now().minusHours(HOT_WITHIN_HOURS);
|
||||
List<CommentResponse> hot = comments.stream()
|
||||
.filter(c -> c.getUpCount() != null && c.getUpCount() >= HOT_MIN_UP
|
||||
&& c.getCreatedAt() != null && c.getCreatedAt().isAfter(cutoff))
|
||||
.sorted((a, b) -> Integer.compare(b.getUpCount(), a.getUpCount()))
|
||||
.limit(HOT_MAX)
|
||||
.toList();
|
||||
if (hot.isEmpty()) {
|
||||
return comments;
|
||||
}
|
||||
java.util.Set<Long> hotIds = new java.util.HashSet<>();
|
||||
hot.forEach(c -> { c.setHot(true); hotIds.add(c.getId()); });
|
||||
List<CommentResponse> ordered = new java.util.ArrayList<>(hot);
|
||||
for (CommentResponse c : comments) {
|
||||
if (!hotIds.contains(c.getId())) ordered.add(c);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** 선택된 태그 ID 중 실제 DB에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */
|
||||
|
||||
@@ -4,10 +4,9 @@ import com.sb.web.board.domain.Tag;
|
||||
import com.sb.web.board.domain.TagCategory;
|
||||
import com.sb.web.board.dto.TagCategoryRequest;
|
||||
import com.sb.web.board.dto.TagCategoryResponse;
|
||||
import com.sb.web.board.dto.BoardSettingResponse;
|
||||
import com.sb.web.board.dto.TagRequest;
|
||||
import com.sb.web.board.dto.TagResponse;
|
||||
import com.sb.web.board.mapper.BoardSettingMapper;
|
||||
import com.sb.web.board.mapper.TagCategoryBoardMapper;
|
||||
import com.sb.web.board.mapper.TagCategoryMapper;
|
||||
import com.sb.web.board.mapper.TagMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
@@ -18,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 태그 카테고리 + 태그 관리 서비스 (관리자 기능 + 그룹 조회).
|
||||
@@ -28,7 +28,10 @@ public class TagService {
|
||||
|
||||
private final TagCategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final BoardSettingMapper boardSettingMapper;
|
||||
private final TagCategoryBoardMapper categoryBoardMapper;
|
||||
|
||||
/** 게시판 매핑에 허용되는 값 */
|
||||
private static final Set<String> VALID_BOARDS = Set.of("community", "saving", "tips");
|
||||
|
||||
/** 카테고리별로 묶은 전체 태그 (관리 화면용) */
|
||||
public List<TagCategoryResponse> listGrouped() {
|
||||
@@ -39,37 +42,43 @@ public class TagService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 글 작성 시 선택 가능한 태그 그룹 — 게시판 설정 카테고리로 제한 (없으면 전체) */
|
||||
public List<TagCategoryResponse> listWritableGroups() {
|
||||
Long catId = boardSettingMapper.findTagCategoryId();
|
||||
if (catId == null) {
|
||||
/**
|
||||
* 글 작성 시 선택 가능한 태그 그룹 — 해당 게시판에 명시적으로 매핑된 그룹만(엄격).
|
||||
* 게시판을 하나도 지정하지 않은 그룹은 어디에도 노출되지 않는다.
|
||||
*/
|
||||
public List<TagCategoryResponse> listWritableGroups(String boardCategory) {
|
||||
// 게시판 미지정(수정 화면 등) → 전체 그룹 (기존 글 태그 유실 방지)
|
||||
if (boardCategory == null || boardCategory.isBlank()) {
|
||||
return listGrouped();
|
||||
}
|
||||
TagCategory c = categoryMapper.findById(catId);
|
||||
return c == null ? listGrouped() : List.of(toGroup(c));
|
||||
List<TagCategoryResponse> result = new ArrayList<>();
|
||||
for (TagCategory c : categoryMapper.findAll()) {
|
||||
TagCategoryResponse g = toGroup(c);
|
||||
List<String> boards = g.getBoards();
|
||||
if (boards != null && boards.contains(boardCategory)) {
|
||||
result.add(g);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private TagCategoryResponse toGroup(TagCategory c) {
|
||||
List<TagResponse> tags = tagMapper.findByCategoryId(c.getId())
|
||||
.stream().map(TagResponse::from).toList();
|
||||
return TagCategoryResponse.of(c, tags);
|
||||
List<String> boards = categoryBoardMapper.findBoards(c.getId());
|
||||
return TagCategoryResponse.of(c, tags, boards);
|
||||
}
|
||||
|
||||
/* ===================== 게시판 설정 ===================== */
|
||||
|
||||
public BoardSettingResponse getBoardSetting() {
|
||||
return BoardSettingResponse.builder()
|
||||
.tagCategoryId(boardSettingMapper.findTagCategoryId())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BoardSettingResponse setBoardSetting(Long tagCategoryId) {
|
||||
if (tagCategoryId != null && categoryMapper.findById(tagCategoryId) == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
||||
/** 카테고리의 게시판 매핑을 교체 저장 (유효 게시판 값만, 중복 제거) */
|
||||
private void saveBoards(Long categoryId, List<String> boards) {
|
||||
categoryBoardMapper.deleteByCategory(categoryId);
|
||||
if (boards == null) {
|
||||
return;
|
||||
}
|
||||
boardSettingMapper.updateTagCategoryId(tagCategoryId);
|
||||
return BoardSettingResponse.builder().tagCategoryId(tagCategoryId).build();
|
||||
boards.stream()
|
||||
.filter(b -> b != null && VALID_BOARDS.contains(b))
|
||||
.distinct()
|
||||
.forEach(b -> categoryBoardMapper.insert(categoryId, b));
|
||||
}
|
||||
|
||||
/* ===================== 카테고리 ===================== */
|
||||
@@ -85,7 +94,8 @@ public class TagService {
|
||||
.sortOrder(req.getSortOrder() != null ? req.getSortOrder() : 0)
|
||||
.build();
|
||||
categoryMapper.insert(c);
|
||||
return TagCategoryResponse.of(c, List.of());
|
||||
saveBoards(c.getId(), req.getBoards());
|
||||
return TagCategoryResponse.of(c, List.of(), categoryBoardMapper.findBoards(c.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -100,11 +110,12 @@ public class TagService {
|
||||
c.setSortOrder(req.getSortOrder());
|
||||
}
|
||||
categoryMapper.update(c);
|
||||
saveBoards(id, req.getBoards());
|
||||
List<TagResponse> tags = tagMapper.findByCategoryId(id).stream().map(TagResponse::from).toList();
|
||||
return TagCategoryResponse.of(c, tags);
|
||||
return TagCategoryResponse.of(c, tags, categoryBoardMapper.findBoards(id));
|
||||
}
|
||||
|
||||
/** 카테고리 삭제 — 소속 태그와 그 연결(post_tag)까지 함께 삭제 */
|
||||
/** 카테고리 삭제 — 소속 태그와 그 연결(post_tag)·게시판 매핑까지 함께 삭제 */
|
||||
@Transactional
|
||||
public void deleteCategory(Long id) {
|
||||
mustFindCategory(id);
|
||||
@@ -112,6 +123,7 @@ public class TagService {
|
||||
tagMapper.deletePostTagsByTagId(t.getId());
|
||||
tagMapper.delete(t.getId());
|
||||
}
|
||||
categoryBoardMapper.deleteByCategory(id);
|
||||
categoryMapper.delete(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ public class CorsConfig implements WebMvcConfigurer {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins(
|
||||
"http://localhost:5173", // Vue 개발 서버
|
||||
"http://localhost:5174", // Vue 개발 서버 (포트 충돌 시 자동 증가)
|
||||
"capacitor://localhost", // Capacitor (iOS scheme)
|
||||
"https://localhost", // Capacitor Android (기본 https scheme)
|
||||
"http://localhost", // Capacitor Android (http scheme)
|
||||
|
||||
@@ -25,9 +25,12 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/auth/password",
|
||||
"/api/auth/verify-password", "/api/auth/profile",
|
||||
"/api/board/**", "/api/admin/**", "/api/account/**");
|
||||
.addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history",
|
||||
"/api/auth/logout", "/api/auth/password",
|
||||
"/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image",
|
||||
"/api/board/**", "/api/admin/**", "/api/account/**", "/api/billing/**", "/api/fx/**")
|
||||
// Google Play RTDN(서버 알림)은 비인증 — Google 이 호출
|
||||
.excludePathPatterns("/api/billing/google/rtdn");
|
||||
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
|
||||
registry.addInterceptor(adminInterceptor)
|
||||
.addPathPatterns("/api/admin/**");
|
||||
@@ -38,7 +41,6 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
"/api/account/category-stats",
|
||||
"/api/account/networth/trend",
|
||||
"/api/account/budgets", "/api/account/budgets/**",
|
||||
"/api/account/recurrings", "/api/account/recurrings/**",
|
||||
"/api/account/ocr", "/api/account/ocr/**",
|
||||
"/api/account/invest", "/api/account/invest/**",
|
||||
"/api/account/tags", "/api/account/tags/**",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.point;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 포인트 적립/차감 내역 항목.
|
||||
*/
|
||||
@Data
|
||||
public class PointHistoryResponse {
|
||||
|
||||
private Integer amount; // 증감 포인트(+/-)
|
||||
private String reason; // BOARD_WRITE 등
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.sb.web.point;
|
||||
|
||||
import com.sb.web.point.mapper.PointMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 활동 포인트 적립/조회.
|
||||
*
|
||||
* 정책 요약:
|
||||
* - 게시글/댓글 작성: 10P, 일 5회(50P) 한도, 스팸 키워드 포함 시 미지급
|
||||
* - 추천 받음(글 작성자): +5P, 일 50P 한도
|
||||
* - 비추천 받음(글 작성자): -1P
|
||||
* - 남의 글 추천(투표자): +1P, 일 10P 한도
|
||||
* - 추천 취소(투표자): -1P
|
||||
* - 가계부 내역 작성: 10P, 일 5회(50P) 한도
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PointService {
|
||||
|
||||
private final PointMapper pointMapper;
|
||||
|
||||
// 게시글/댓글 작성
|
||||
public static final int BOARD_WRITE_POINT = 10;
|
||||
public static final int BOARD_WRITE_DAILY_LIMIT = 5; // 10P × 5 = 일 50P
|
||||
public static final String REASON_BOARD_WRITE = "BOARD_WRITE";
|
||||
|
||||
// 추천 받음 (글 작성자)
|
||||
public static final int POST_UPVOTED_POINT = 5;
|
||||
public static final int POST_UPVOTED_DAILY_CAP = 10; // 5P × 10 = 일 50P
|
||||
public static final String REASON_POST_UPVOTED = "POST_UPVOTED";
|
||||
|
||||
// 비추천 받음 (글 작성자)
|
||||
public static final int POST_DOWNVOTED_POINT = -1;
|
||||
public static final String REASON_POST_DOWNVOTED = "POST_DOWNVOTED";
|
||||
|
||||
// 남의 글 추천 (투표자)
|
||||
public static final int VOTE_REWARD_POINT = 1;
|
||||
public static final int VOTE_REWARD_DAILY_CAP = 10; // 1P × 10 = 일 10P
|
||||
public static final String REASON_VOTE_REWARD = "VOTE_REWARD";
|
||||
|
||||
// 추천 취소 (투표자)
|
||||
public static final int VOTE_CANCEL_POINT = -1;
|
||||
public static final String REASON_VOTE_CANCEL = "VOTE_CANCEL";
|
||||
|
||||
// 가계부 내역 작성
|
||||
public static final int ACCOUNT_ENTRY_POINT = 10;
|
||||
public static final int ACCOUNT_ENTRY_DAILY_LIMIT = 5; // 10P × 5 = 일 50P
|
||||
public static final String REASON_ACCOUNT_ENTRY = "ACCOUNT_ENTRY";
|
||||
|
||||
// 한 달 개근(그 달 모든 날에 기록) — 랜덤 1~100P, 월 1회. reason 에 연월 인코딩(MONTH_COMPLETE_yyyyMM)
|
||||
public static final int MONTH_COMPLETE_MAX_POINT = 100;
|
||||
public static final String REASON_MONTH_COMPLETE_PREFIX = "MONTH_COMPLETE_";
|
||||
|
||||
// 도배 방지 키워드 — 포함 시 작성 포인트 미지급
|
||||
private static final List<String> SPAM_KEYWORDS = List.of(
|
||||
"http://", "https://", "bit.ly", "goo.gl",
|
||||
"무료지급", "무료쿠폰", "공짜", "홍보", "광고", "클릭하세요",
|
||||
"카지노", "토토", "바카라", "도박"
|
||||
);
|
||||
|
||||
public boolean isSpam(String content) {
|
||||
if (content == null || content.isBlank()) return false;
|
||||
String lower = content.toLowerCase();
|
||||
return SPAM_KEYWORDS.stream().anyMatch(lower::contains);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글/댓글 작성 보상. 스팸 키워드 포함 또는 일 한도 초과 시 미지급.
|
||||
* @return 지급 포인트 (미지급이면 0)
|
||||
*/
|
||||
@Transactional
|
||||
public int awardBoardWrite(Long memberId, String content) {
|
||||
if (isSpam(content)) return 0;
|
||||
int today = pointMapper.countTodayGrants(memberId, REASON_BOARD_WRITE);
|
||||
if (today >= BOARD_WRITE_DAILY_LIMIT) return 0;
|
||||
pointMapper.insertHistory(memberId, BOARD_WRITE_POINT, REASON_BOARD_WRITE);
|
||||
pointMapper.addPoints(memberId, BOARD_WRITE_POINT);
|
||||
return BOARD_WRITE_POINT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 투표 포인트 처리. 이전 투표(existing)와 새 요청(newType)을 비교해 적절한 포인트를 지급/차감한다.
|
||||
*
|
||||
* 케이스:
|
||||
* - existing==newType (취소): UP 취소 → 투표자 -1P / DOWN 취소 → 변동 없음
|
||||
* - 새 UP: 작성자 +5P(일 50P 한도), 투표자 +1P(일 10P 한도)
|
||||
* - 새 DOWN: 작성자 -1P
|
||||
* - UP→DOWN 전환: 투표자 -1P(취소), 작성자 -1P(비추)
|
||||
* - DOWN→UP 전환: 작성자 +5P, 투표자 +1P
|
||||
*/
|
||||
@Transactional
|
||||
public void applyVotePoints(Long authorId, Long voterId, String existing, String newType) {
|
||||
boolean cancel = newType.equals(existing);
|
||||
|
||||
if (cancel) {
|
||||
// 같은 타입 재요청 = 취소
|
||||
if ("UP".equals(existing)) {
|
||||
// 투표자가 받았던 +1P 회수
|
||||
pointMapper.insertHistory(voterId, VOTE_CANCEL_POINT, REASON_VOTE_CANCEL);
|
||||
pointMapper.addPoints(voterId, VOTE_CANCEL_POINT);
|
||||
}
|
||||
// DOWN 취소: 포인트 변동 없음
|
||||
return;
|
||||
}
|
||||
|
||||
// UP→DOWN 전환: 투표자의 추천 보상 먼저 회수
|
||||
if ("UP".equals(existing)) {
|
||||
pointMapper.insertHistory(voterId, VOTE_CANCEL_POINT, REASON_VOTE_CANCEL);
|
||||
pointMapper.addPoints(voterId, VOTE_CANCEL_POINT);
|
||||
}
|
||||
|
||||
if ("UP".equals(newType)) {
|
||||
// 작성자: +5P (일 한도)
|
||||
if (pointMapper.countTodayGrants(authorId, REASON_POST_UPVOTED) < POST_UPVOTED_DAILY_CAP) {
|
||||
pointMapper.insertHistory(authorId, POST_UPVOTED_POINT, REASON_POST_UPVOTED);
|
||||
pointMapper.addPoints(authorId, POST_UPVOTED_POINT);
|
||||
}
|
||||
// 투표자: +1P (일 한도)
|
||||
if (pointMapper.countTodayGrants(voterId, REASON_VOTE_REWARD) < VOTE_REWARD_DAILY_CAP) {
|
||||
pointMapper.insertHistory(voterId, VOTE_REWARD_POINT, REASON_VOTE_REWARD);
|
||||
pointMapper.addPoints(voterId, VOTE_REWARD_POINT);
|
||||
}
|
||||
} else if ("DOWN".equals(newType)) {
|
||||
// 작성자: -1P
|
||||
pointMapper.insertHistory(authorId, POST_DOWNVOTED_POINT, REASON_POST_DOWNVOTED);
|
||||
pointMapper.addPoints(authorId, POST_DOWNVOTED_POINT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 가계부 내역 작성 보상. 건당 10P, 하루 5건(50P)까지만 지급한다.
|
||||
* @return 지급되면 지급 포인트, 한도 초과로 미지급이면 0
|
||||
*/
|
||||
@Transactional
|
||||
public int awardAccountEntry(Long memberId) {
|
||||
int today = pointMapper.countTodayGrants(memberId, REASON_ACCOUNT_ENTRY);
|
||||
if (today >= ACCOUNT_ENTRY_DAILY_LIMIT) {
|
||||
return 0;
|
||||
}
|
||||
pointMapper.insertHistory(memberId, ACCOUNT_ENTRY_POINT, REASON_ACCOUNT_ENTRY);
|
||||
pointMapper.addPoints(memberId, ACCOUNT_ENTRY_POINT);
|
||||
return ACCOUNT_ENTRY_POINT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 달의 모든 날에 기록이 있을 때 주는 랜덤 보너스(1~100P). 월 1회만 지급한다.
|
||||
* reason 에 연월을 인코딩(MONTH_COMPLETE_yyyyMM)해 같은 달 중복 지급을 막는다.
|
||||
* @return 지급 포인트(이미 지급됐으면 0)
|
||||
*/
|
||||
@Transactional
|
||||
public int awardMonthComplete(Long memberId, int year, int month) {
|
||||
String reason = REASON_MONTH_COMPLETE_PREFIX + String.format("%04d%02d", year, month);
|
||||
if (pointMapper.countByReason(memberId, reason) > 0) return 0; // 이미 지급됨
|
||||
int amount = java.util.concurrent.ThreadLocalRandom.current().nextInt(1, MONTH_COMPLETE_MAX_POINT + 1); // 1~100
|
||||
pointMapper.insertHistory(memberId, amount, reason);
|
||||
pointMapper.addPoints(memberId, amount);
|
||||
return amount;
|
||||
}
|
||||
|
||||
public long getPoints(Long memberId) {
|
||||
Long p = pointMapper.getPoints(memberId);
|
||||
return p == null ? 0L : p;
|
||||
}
|
||||
|
||||
/** 최근 포인트 적립/차감 내역 (최신순, 최대 100건) */
|
||||
public java.util.List<PointHistoryResponse> getHistory(Long memberId) {
|
||||
return pointMapper.findHistory(memberId, 100);
|
||||
}
|
||||
|
||||
/** 특정 연월 포인트 적립/차감 내역 (최신순, 전건) */
|
||||
public java.util.List<PointHistoryResponse> getHistory(Long memberId, int year, int month) {
|
||||
return pointMapper.findHistoryByMonth(memberId, year, month);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.sb.web.point.mapper;
|
||||
|
||||
import com.sb.web.point.PointHistoryResponse;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 포인트(member.points) 및 적립 이력(point_history) 매퍼.
|
||||
*/
|
||||
@Mapper
|
||||
public interface PointMapper {
|
||||
|
||||
/** 오늘 특정 사유로 지급된 횟수 (일일 한도 계산용) */
|
||||
int countTodayGrants(@Param("memberId") Long memberId, @Param("reason") String reason);
|
||||
|
||||
/** 특정 사유로 지금까지 지급된 총 횟수 (월 1회 등 전체기간 중복 방지용) */
|
||||
int countByReason(@Param("memberId") Long memberId, @Param("reason") String reason);
|
||||
|
||||
int insertHistory(@Param("memberId") Long memberId, @Param("amount") int amount, @Param("reason") String reason);
|
||||
|
||||
int addPoints(@Param("memberId") Long memberId, @Param("amount") int amount);
|
||||
|
||||
Long getPoints(@Param("memberId") Long memberId);
|
||||
|
||||
/** 최근 적립/차감 내역 (최신순, 최대 limit건) */
|
||||
List<PointHistoryResponse> findHistory(@Param("memberId") Long memberId, @Param("limit") int limit);
|
||||
|
||||
/** 특정 연월 적립/차감 내역 (최신순, 전건) */
|
||||
List<PointHistoryResponse> findHistoryByMonth(@Param("memberId") Long memberId, @Param("year") int year, @Param("month") int month);
|
||||
}
|
||||
@@ -20,9 +20,10 @@ spring:
|
||||
max-request-size: 12MB
|
||||
|
||||
# ===== SQL 초기화 ===== 기동 시 member 테이블 자동 생성(IF NOT EXISTS 라 멱등)
|
||||
# 로컬: never (원격 DB에 매번 DDL 실행 시 기동 지연). 운영: always (배포서버 환경변수로 덮어씀)
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
mode: ${SQL_INIT_MODE:never}
|
||||
schema-locations: classpath:db/member.sql,classpath:db/board.sql,classpath:db/account.sql,classpath:db/dev-seed.sql
|
||||
continue-on-error: true
|
||||
|
||||
@@ -78,9 +79,20 @@ app:
|
||||
public-url: ${PUBLIC_URL:https://app.sblog.kr}
|
||||
# 구글 OAuth 웹 클라이언트 ID (Google Cloud Console 발급). 미설정 시 구글 로그인 비활성.
|
||||
google-client-id: ${GOOGLE_CLIENT_ID:}
|
||||
# 애플 로그인 aud(=iOS 앱 번들 ID). 네이티브 Sign in with Apple 의 identity token 대상값.
|
||||
apple-client-id: ${APPLE_CLIENT_ID:kr.sblog.slimbudget.app}
|
||||
# AI 자연어 입력 파싱(Claude). 키 미설정 시 규칙기반 파서로 폴백(무중단). Haiku 권장(학습 미사용·저렴).
|
||||
anthropic-api-key: ${ANTHROPIC_API_KEY:}
|
||||
anthropic-model: ${ANTHROPIC_MODEL:claude-haiku-4-5}
|
||||
|
||||
# ===== Logging =====
|
||||
logging:
|
||||
file:
|
||||
name: logs/app.log
|
||||
logback:
|
||||
rollingpolicy:
|
||||
file-name-pattern: logs/app.%d{yyyy-MM-dd}.%i.log
|
||||
max-history: 30
|
||||
level:
|
||||
root: INFO
|
||||
com.sb.web: DEBUG
|
||||
|
||||
@@ -28,6 +28,12 @@ ALTER TABLE wallet ADD COLUMN IF NOT EXISTS opening_balance BIGINT NOT NULL DEFA
|
||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS opening_date DATE NULL;
|
||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS current_value BIGINT NULL;
|
||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_rate DECIMAL(7,4) NULL COMMENT '연이자율(%) 예: 5.2500';
|
||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_method VARCHAR(20) NULL COMMENT '상환방식: EQUAL_PAYMENT/EQUAL_PRINCIPAL/BULLET';
|
||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_months INT NULL COMMENT '대출기간(개월)';
|
||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_start DATE NULL COMMENT '대출시작일';
|
||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_amount BIGINT NULL COMMENT '대출 실행 금액(원금, 처음 빌린 금액)';
|
||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS loan_payment BIGINT NULL COMMENT '월 상환금(고정, 원리금균등) — 은행 조회액과 맞추기 위한 선택 입력';
|
||||
-- 계좌번호 암호화 저장(AES-GCM)으로 평문보다 길어 VARCHAR(255) 필요 — 운영 적용 완료(CREATE TABLE 정의에 반영).
|
||||
-- 매 기동 MODIFY 는 라이브 테이블 락 위험이 있어 제거. 기존 환경 확장이 필요하면 1회 수동 적용:
|
||||
-- ALTER TABLE wallet MODIFY account_number VARCHAR(255) NULL;
|
||||
@@ -59,6 +65,10 @@ ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS installment_months INT NULL;
|
||||
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS pending TINYINT(1) NOT NULL DEFAULT 0 COMMENT '확인 필요(미확정) 여부';
|
||||
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS notif_key VARCHAR(160) NULL COMMENT '알림 자동인식 중복방지 키';
|
||||
ALTER TABLE account_entry ADD INDEX IF NOT EXISTS idx_entry_notif (member_id, notif_key);
|
||||
-- 외화 결제: amount 는 환산된 원화(표준값, 통계·예산은 이 값 사용). 아래는 외화 원본 보존용.
|
||||
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS currency VARCHAR(3) NULL COMMENT '통화코드(USD/JPY 등). NULL/KRW=원화';
|
||||
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS original_amount DECIMAL(18,2) NULL COMMENT '외화 원본 금액';
|
||||
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS exchange_rate DECIMAL(18,6) NULL COMMENT '적용 환율(외화 1단위→원화)';
|
||||
|
||||
-- 가계부 태그 (사용자별 관리 — 게시판 태그와 분리)
|
||||
CREATE TABLE IF NOT EXISTS account_tag (
|
||||
@@ -161,6 +171,15 @@ CREATE TABLE IF NOT EXISTS budget (
|
||||
UNIQUE KEY uk_budget_member_category (member_id, category)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 월별 예산: 예산을 연/월별로 분리(전월 복사로 다음 달에 동일 예산 저장 가능).
|
||||
ALTER TABLE budget ADD COLUMN IF NOT EXISTS `year` INT NULL COMMENT '예산 연도';
|
||||
ALTER TABLE budget ADD COLUMN IF NOT EXISTS `month` INT NULL COMMENT '예산 월(1~12)';
|
||||
-- 기존 상시 예산을 현재 연/월로 1회 이관
|
||||
UPDATE budget SET `year` = YEAR(NOW()), `month` = MONTH(NOW()) WHERE `year` IS NULL OR `month` IS NULL;
|
||||
-- 분류 유니크를 (member,category) → (member,category,year,month) 로 교체
|
||||
ALTER TABLE budget DROP INDEX IF EXISTS uk_budget_member_category;
|
||||
ALTER TABLE budget ADD UNIQUE KEY IF NOT EXISTS uk_budget_member_cat_ym (member_id, category, `year`, `month`);
|
||||
|
||||
-- 가계부 항목 - 태그 (다대다, tag_id 는 account_tag.id 참조)
|
||||
CREATE TABLE IF NOT EXISTS account_entry_tag (
|
||||
entry_id BIGINT NOT NULL,
|
||||
@@ -179,6 +198,21 @@ CREATE TABLE IF NOT EXISTS budget_income (
|
||||
PRIMARY KEY (member_id, `year`, `month`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 1회성 예산 (기간·제목별 단발 예산)
|
||||
CREATE TABLE IF NOT EXISTS budget_onetime (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
member_id BIGINT NOT NULL COMMENT '소유자 member.id',
|
||||
title VARCHAR(100) NOT NULL COMMENT '제목 (예: 7월 여행)',
|
||||
category VARCHAR(50) NULL COMMENT '카테고리 (NULL=전체 지출)',
|
||||
amount BIGINT NOT NULL COMMENT '총 예산',
|
||||
start_date DATE NOT NULL COMMENT '기간 시작일',
|
||||
end_date DATE NOT NULL COMMENT '기간 종료일',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_bot_member_date (member_id, start_date, end_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 투자 보유 종목 (INVEST 지갑별 · 사용자별)
|
||||
CREATE TABLE IF NOT EXISTS invest_holding (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -54,6 +54,15 @@ CREATE TABLE IF NOT EXISTS tag (
|
||||
-- 기존 tag 테이블에 category_id 가 없으면 추가 (멱등)
|
||||
ALTER TABLE tag ADD COLUMN IF NOT EXISTS category_id BIGINT NULL;
|
||||
|
||||
-- 태그 카테고리(그룹) ↔ 게시판 매핑. 어느 게시판(community/saving/tips)에서 이 그룹을 쓸지 지정.
|
||||
-- 매핑이 하나도 없는 카테고리는 모든 게시판에서 사용(하위호환).
|
||||
CREATE TABLE IF NOT EXISTS tag_category_board (
|
||||
tag_category_id BIGINT NOT NULL,
|
||||
board_category VARCHAR(30) NOT NULL COMMENT 'community / saving / tips',
|
||||
PRIMARY KEY (tag_category_id, board_category),
|
||||
KEY idx_tcb_board (board_category)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 게시글-태그 (다대다)
|
||||
CREATE TABLE IF NOT EXISTS post_tag (
|
||||
post_id BIGINT NOT NULL,
|
||||
@@ -62,16 +71,6 @@ CREATE TABLE IF NOT EXISTS post_tag (
|
||||
KEY idx_post_tag_tag (tag_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 게시판 설정 (단일 행) — 게시판에서 사용할 태그 카테고리 지정
|
||||
CREATE TABLE IF NOT EXISTS board_setting (
|
||||
id BIGINT NOT NULL,
|
||||
tag_category_id BIGINT NULL COMMENT '이 게시판에서 사용 가능한 태그 카테고리',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO board_setting (id, tag_category_id) VALUES (1, NULL);
|
||||
|
||||
-- 게시글 인라인 이미지 (DB 저장). 본문에는 /api/images/{id} URL 만 포함(base64 미사용).
|
||||
CREATE TABLE IF NOT EXISTS board_image (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
@@ -95,3 +94,39 @@ CREATE TABLE IF NOT EXISTS comment (
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_comment_post (post_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 댓글 신고 누적 블라인드 플래그 (멱등)
|
||||
ALTER TABLE comment ADD COLUMN IF NOT EXISTS blocked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '신고 누적 블라인드';
|
||||
|
||||
-- 신고 (글/댓글). 회원당 대상별 1회. 5건 누적 시 블라인드(관리자만 열람).
|
||||
CREATE TABLE IF NOT EXISTS report (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
target_type VARCHAR(10) NOT NULL COMMENT 'POST / COMMENT',
|
||||
target_id BIGINT NOT NULL,
|
||||
member_id BIGINT NOT NULL COMMENT '신고자',
|
||||
reason VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_report (target_type, target_id, member_id),
|
||||
KEY idx_report_target (target_type, target_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 게시글 추천/비추천 (회원당 1표, UP/DOWN). 같은 표 재요청 시 취소, 반대면 전환.
|
||||
CREATE TABLE IF NOT EXISTS post_vote (
|
||||
post_id BIGINT NOT NULL,
|
||||
member_id BIGINT NOT NULL,
|
||||
vote_type VARCHAR(4) NOT NULL COMMENT 'UP / DOWN',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (post_id, member_id),
|
||||
KEY idx_post_vote_post (post_id, vote_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 댓글 추천/비추천 (회원당 1표)
|
||||
CREATE TABLE IF NOT EXISTS comment_vote (
|
||||
comment_id BIGINT NOT NULL,
|
||||
member_id BIGINT NOT NULL,
|
||||
vote_type VARCHAR(4) NOT NULL COMMENT 'UP / DOWN',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (comment_id, member_id),
|
||||
KEY idx_comment_vote_comment (comment_id, vote_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -27,9 +27,59 @@ ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_google_id (google_id);
|
||||
-- 기존 구글 계정(provider=GOOGLE)의 sub 를 google_id 로 백필(멱등 — 이미 채워졌으면 아무 것도 안 함).
|
||||
UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_id IS NULL AND provider_id IS NOT NULL;
|
||||
|
||||
-- 애플 로그인: 애플 sub 를 apple_id 로 저장(구글과 동일 — LOCAL 계정에 연결해도 provider 유지). 멱등.
|
||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS apple_id VARCHAR(100) NULL COMMENT '애플 sub (계정 연결용)' AFTER google_id;
|
||||
ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_apple_id (apple_id);
|
||||
|
||||
-- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등).
|
||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role;
|
||||
|
||||
-- 프로필 사진. google_picture 는 구글 로그인 시 동기화되는 구글 아바타 URL,
|
||||
-- profile_image 는 사용자가 직접 올린 커스텀 이미지(data URL). 표시 우선순위: profile_image > google_picture > 이니셜.
|
||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS google_picture VARCHAR(500) NULL COMMENT '구글 아바타 URL (로그인 시 동기화)' AFTER google_id;
|
||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS profile_image MEDIUMTEXT NULL COMMENT '사용자 지정 프로필 이미지(data URL)' AFTER google_picture;
|
||||
|
||||
-- 포인트(게시판 글/댓글 작성 보상). 기존 회원은 0 으로 시작 (멱등).
|
||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COMMENT '활동 포인트' AFTER plan;
|
||||
|
||||
-- 멤버십 만료일 (유료 구독). NULL = 만료 없음(관리자 영구 부여) 또는 무료. 만료 지나면 스케줄러가 FREE 로 강등.
|
||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_expires_at DATETIME NULL COMMENT '유료 멤버십 만료일시' AFTER plan;
|
||||
|
||||
-- 구독 상품(premium_monthly 등) + 자동 갱신 여부 (정기결제 관리)
|
||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_product VARCHAR(100) NULL COMMENT '구독 상품 ID' AFTER plan_expires_at;
|
||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_auto_renew TINYINT(1) NOT NULL DEFAULT 0 COMMENT '자동 갱신(해지 시 0)' AFTER plan_product;
|
||||
|
||||
-- 인앱 결제 기록 (영수증 검증·중복 방지·감사)
|
||||
CREATE TABLE IF NOT EXISTS iap_purchase (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
member_id BIGINT NOT NULL,
|
||||
platform VARCHAR(20) NOT NULL COMMENT 'GOOGLE_PLAY / TEST',
|
||||
product_id VARCHAR(100) NOT NULL COMMENT '상품 ID(premium_monthly 등)',
|
||||
purchase_token VARCHAR(512) NOT NULL COMMENT 'Play 구매 토큰(또는 테스트 토큰)',
|
||||
order_id VARCHAR(120) NULL,
|
||||
status VARCHAR(20) NOT NULL COMMENT 'VERIFIED / FAILED',
|
||||
expires_at DATETIME NULL COMMENT '이 결제로 부여된 만료일',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_iap_token (purchase_token),
|
||||
KEY idx_iap_member (member_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 포인트 상점: 추가 계좌 슬롯(무료 회원 500P/개, 최대 3개), AI 통계 크레딧(1000P → 10회)
|
||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS extra_wallet_slots INT NOT NULL DEFAULT 0 COMMENT '포인트 상점 구매 추가 계좌 슬롯 수 (최대 3)' AFTER points;
|
||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS ai_stat_credits INT NOT NULL DEFAULT 0 COMMENT '포인트 상점 구매 AI 통계 크레딧 잔여 횟수' AFTER extra_wallet_slots;
|
||||
|
||||
-- 포인트 적립/차감 이력 (감사 + 일일 지급 한도 계산용).
|
||||
CREATE TABLE IF NOT EXISTS point_history (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
member_id BIGINT NOT NULL,
|
||||
amount INT NOT NULL COMMENT '증감 포인트(+/-)',
|
||||
reason VARCHAR(30) NOT NULL COMMENT 'BOARD_WRITE 등',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_point_history_member_date (member_id, reason, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================
|
||||
-- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경.
|
||||
-- ============================================================
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
<result property="installmentMonths" column="installment_months"/>
|
||||
<result property="pending" column="pending"/>
|
||||
<result property="notifKey" column="notif_key"/>
|
||||
<result property="currency" column="currency"/>
|
||||
<result property="originalAmount" column="original_amount"/>
|
||||
<result property="exchangeRate" column="exchange_rate"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
<result property="updatedAt" column="updated_at"/>
|
||||
</resultMap>
|
||||
@@ -32,6 +35,7 @@
|
||||
e.wallet_id, w.name AS wallet_name,
|
||||
e.to_wallet_id, tw.name AS to_wallet_name,
|
||||
e.installment_months, e.pending, e.notif_key,
|
||||
e.currency, e.original_amount, e.exchange_rate,
|
||||
e.created_at, e.updated_at
|
||||
</sql>
|
||||
<sql id="entryJoins">
|
||||
@@ -141,17 +145,20 @@
|
||||
useGeneratedKeys="true" keyProperty="id">
|
||||
INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo,
|
||||
wallet_id, to_wallet_id, installment_months, pending, notif_key,
|
||||
currency, original_amount, exchange_rate,
|
||||
created_at, updated_at)
|
||||
VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo},
|
||||
#{walletId}, #{toWalletId}, #{installmentMonths},
|
||||
COALESCE(#{pending}, 0), #{notifKey}, NOW(), NOW())
|
||||
COALESCE(#{pending}, 0), #{notifKey},
|
||||
#{currency}, #{originalAmount}, #{exchangeRate}, NOW(), NOW())
|
||||
</insert>
|
||||
|
||||
<update id="update" parameterType="com.sb.web.account.domain.AccountEntry">
|
||||
UPDATE account_entry
|
||||
SET entry_date = #{entryDate}, type = #{type}, category = #{category},
|
||||
amount = #{amount}, memo = #{memo}, wallet_id = #{walletId}, to_wallet_id = #{toWalletId},
|
||||
installment_months = #{installmentMonths}
|
||||
installment_months = #{installmentMonths},
|
||||
currency = #{currency}, original_amount = #{originalAmount}, exchange_rate = #{exchangeRate}
|
||||
WHERE id = #{id} AND member_id = #{memberId}
|
||||
</update>
|
||||
|
||||
@@ -227,4 +234,25 @@
|
||||
ORDER BY t.name
|
||||
</select>
|
||||
|
||||
<!-- 복수 항목의 태그를 한 번에 조회 — {entry_id, tag_name} 행 반환 -->
|
||||
<select id="findTagsByEntryIds" resultType="map">
|
||||
SELECT aet.entry_id AS entry_id, t.name AS tag_name
|
||||
FROM account_tag t
|
||||
JOIN account_entry_tag aet ON aet.tag_id = t.id
|
||||
WHERE aet.entry_id IN
|
||||
<foreach item="id" collection="entryIds" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
ORDER BY aet.entry_id, t.name
|
||||
</select>
|
||||
|
||||
<!-- 특정 연·월에 기록이 있는 서로 다른 날짜 수(한 달 개근 판정) -->
|
||||
<select id="countDistinctEntryDays" resultType="int">
|
||||
SELECT COUNT(DISTINCT DAY(entry_date))
|
||||
FROM account_entry
|
||||
WHERE member_id = #{memberId}
|
||||
AND YEAR(entry_date) = #{year}
|
||||
AND MONTH(entry_date) = #{month}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
UPDATE auth_session SET name = #{name} WHERE token = #{token}
|
||||
</update>
|
||||
|
||||
<update id="updatePlan">
|
||||
UPDATE auth_session SET plan = #{plan} WHERE token = #{token}
|
||||
</update>
|
||||
|
||||
<delete id="delete">
|
||||
DELETE FROM auth_session WHERE token = #{token}
|
||||
</delete>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user