From 2a2f81dc32c3e40b357ac67b90bdc04c152ab74b Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 31 May 2026 15:43:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B0=80=EA=B3=84=EB=B6=80=C2=B7?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EB=B0=B1=EC=97=94=EB=93=9C=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - com.sb.web 패키지로 재구성: account / auth / board / user / admin / common - 가계부(account): 내역(필터), 계좌·순자산, 예산, 분류, 정기 거래, 투자 포트폴리오 (종목·매매 이력, 이동평균 평단·실현/평가손익) - MyBatis + MariaDB + Redis 세션, BCrypt - 스키마 자동 초기화(db/*.sql, 멱등), 사용자별 데이터 격리(member_id) - 문서: docs/BACKEND.md, .env.example Co-Authored-By: Claude Opus 4.8 --- .env.example | 13 + .gitignore | 3 + build.gradle | 4 +- docs/BACKEND.md | 86 ++++ .../sb_bt => sb/web}/SbBtApplication.java | 4 +- .../account/controller/AccountController.java | 196 ++++++++ .../account/controller/BudgetController.java | 74 +++ .../controller/CategoryController.java | 61 +++ .../account/controller/InvestController.java | 87 ++++ .../controller/RecurringController.java | 62 +++ .../web/account/domain/AccountCategory.java | 26 + .../sb/web/account/domain/AccountEntry.java | 34 ++ .../com/sb/web/account/domain/AccountTag.java | 24 + .../com/sb/web/account/domain/Budget.java | 33 ++ .../sb/web/account/domain/InvestHolding.java | 28 ++ .../sb/web/account/domain/InvestTrade.java | 29 ++ .../com/sb/web/account/domain/Recurring.java | 42 ++ .../com/sb/web/account/domain/Wallet.java | 34 ++ .../web/account/dto/AccountEntryRequest.java | 44 ++ .../web/account/dto/AccountEntryResponse.java | 44 ++ .../sb/web/account/dto/AccountSummary.java | 24 + .../sb/web/account/dto/AccountTagRequest.java | 16 + .../web/account/dto/AccountTagResponse.java | 20 + .../sb/web/account/dto/BudgetPeriodStat.java | 16 + .../com/sb/web/account/dto/BudgetRequest.java | 30 ++ .../sb/web/account/dto/BudgetResponse.java | 37 ++ .../com/sb/web/account/dto/BudgetStatus.java | 19 + .../sb/web/account/dto/CategoryRequest.java | 21 + .../sb/web/account/dto/CategoryResponse.java | 21 + .../com/sb/web/account/dto/CategoryStat.java | 15 + .../sb/web/account/dto/HoldingRequest.java | 28 ++ .../sb/web/account/dto/HoldingResponse.java | 26 + .../com/sb/web/account/dto/NetWorthPoint.java | 15 + .../sb/web/account/dto/NetWorthResponse.java | 16 + .../com/sb/web/account/dto/PeriodStat.java | 16 + .../sb/web/account/dto/RecurringRequest.java | 52 ++ .../sb/web/account/dto/RecurringResponse.java | 35 ++ .../sb/web/account/dto/RepaymentRequest.java | 33 ++ .../com/sb/web/account/dto/TradeRequest.java | 32 ++ .../com/sb/web/account/dto/TradeResponse.java | 37 ++ .../com/sb/web/account/dto/WalletRequest.java | 40 ++ .../sb/web/account/dto/WalletResponse.java | 45 ++ .../account/mapper/AccountEntryMapper.java | 77 +++ .../web/account/mapper/AccountTagMapper.java | 34 ++ .../sb/web/account/mapper/BudgetMapper.java | 33 ++ .../sb/web/account/mapper/CategoryMapper.java | 31 ++ .../sb/web/account/mapper/InvestMapper.java | 44 ++ .../web/account/mapper/RecurringMapper.java | 30 ++ .../sb/web/account/mapper/WalletMapper.java | 28 ++ .../web/account/service/AccountService.java | 465 ++++++++++++++++++ .../sb/web/account/service/BudgetService.java | 230 +++++++++ .../web/account/service/CategoryService.java | 89 ++++ .../sb/web/account/service/InvestService.java | 235 +++++++++ .../web/account/service/RecurringService.java | 178 +++++++ .../admin/controller/AdminTagController.java | 83 ++++ .../web/auth/controller/AuthController.java | 51 ++ .../java/com/sb/web/auth/domain/Member.java | 32 ++ .../com/sb/web/auth/dto/LoginRequest.java | 17 + .../com/sb/web/auth/dto/LoginResponse.java | 17 + .../com/sb/web/auth/dto/MemberResponse.java | 31 ++ .../java/com/sb/web/auth/dto/SessionUser.java | 36 ++ .../com/sb/web/auth/dto/SignupRequest.java | 27 + .../com/sb/web/auth/mapper/MemberMapper.java | 23 + .../com/sb/web/auth/service/AuthService.java | 102 ++++ .../com/sb/web/auth/web/AdminInterceptor.java | 36 ++ .../com/sb/web/auth/web/AuthInterceptor.java | 59 +++ .../web/board/controller/BoardController.java | 127 +++++ .../java/com/sb/web/board/domain/Comment.java | 26 + .../java/com/sb/web/board/domain/Post.java | 30 ++ .../java/com/sb/web/board/domain/Tag.java | 22 + .../com/sb/web/board/domain/TagCategory.java | 24 + .../sb/web/board/dto/BoardSettingRequest.java | 11 + .../web/board/dto/BoardSettingResponse.java | 13 + .../com/sb/web/board/dto/CommentRequest.java | 16 + .../com/sb/web/board/dto/CommentResponse.java | 31 ++ .../com/sb/web/board/dto/PageResponse.java | 31 ++ .../java/com/sb/web/board/dto/PostDetail.java | 60 +++ .../com/sb/web/board/dto/PostRequest.java | 24 + .../com/sb/web/board/dto/PostSummary.java | 22 + .../sb/web/board/dto/TagCategoryRequest.java | 18 + .../sb/web/board/dto/TagCategoryResponse.java | 29 ++ .../java/com/sb/web/board/dto/TagRequest.java | 20 + .../com/sb/web/board/dto/TagResponse.java | 25 + .../web/board/mapper/BoardSettingMapper.java | 13 + .../sb/web/board/mapper/CommentMapper.java | 21 + .../com/sb/web/board/mapper/PostMapper.java | 36 ++ .../web/board/mapper/TagCategoryMapper.java | 23 + .../com/sb/web/board/mapper/TagMapper.java | 40 ++ .../sb/web/board/service/BoardService.java | 215 ++++++++ .../com/sb/web/board/service/TagService.java | 170 +++++++ .../web/common}/config/CorsConfig.java | 2 +- .../sb/web/common/config/PasswordConfig.java | 19 + .../web/common}/config/RedisConfig.java | 21 +- .../com/sb/web/common/config/WebConfig.java | 31 ++ .../common}/controller/HealthController.java | 2 +- .../sb/web/common/exception/ApiException.java | 18 + .../exception/GlobalExceptionHandler.java | 48 ++ .../web/user}/controller/UserController.java | 6 +- .../sb_bt => sb/web/user}/domain/User.java | 2 +- .../web/user}/mapper/UserMapper.java | 4 +- .../web/user}/service/UserService.java | 6 +- src/main/resources/application.yml | 36 +- src/main/resources/db/account.sql | 154 ++++++ src/main/resources/db/board.sql | 79 +++ src/main/resources/db/dev-seed.sql | 11 + src/main/resources/db/member.sql | 21 + .../resources/mapper/AccountEntryMapper.xml | 173 +++++++ .../resources/mapper/AccountTagMapper.xml | 59 +++ .../resources/mapper/BoardSettingMapper.xml | 14 + src/main/resources/mapper/BudgetMapper.xml | 62 +++ src/main/resources/mapper/CategoryMapper.xml | 54 ++ src/main/resources/mapper/CommentMapper.xml | 42 ++ src/main/resources/mapper/InvestMapper.xml | 98 ++++ src/main/resources/mapper/MemberMapper.xml | 49 ++ src/main/resources/mapper/PostMapper.xml | 80 +++ src/main/resources/mapper/RecurringMapper.xml | 81 +++ .../resources/mapper/TagCategoryMapper.xml | 47 ++ src/main/resources/mapper/TagMapper.xml | 77 +++ src/main/resources/mapper/UserMapper.xml | 2 +- src/main/resources/mapper/WalletMapper.xml | 73 +++ .../web}/SbBtApplicationTests.java | 2 +- 121 files changed, 5899 insertions(+), 31 deletions(-) create mode 100644 .env.example create mode 100644 docs/BACKEND.md rename src/main/java/com/{example/sb_bt => sb/web}/SbBtApplication.java (69%) create mode 100644 src/main/java/com/sb/web/account/controller/AccountController.java create mode 100644 src/main/java/com/sb/web/account/controller/BudgetController.java create mode 100644 src/main/java/com/sb/web/account/controller/CategoryController.java create mode 100644 src/main/java/com/sb/web/account/controller/InvestController.java create mode 100644 src/main/java/com/sb/web/account/controller/RecurringController.java create mode 100644 src/main/java/com/sb/web/account/domain/AccountCategory.java create mode 100644 src/main/java/com/sb/web/account/domain/AccountEntry.java create mode 100644 src/main/java/com/sb/web/account/domain/AccountTag.java create mode 100644 src/main/java/com/sb/web/account/domain/Budget.java create mode 100644 src/main/java/com/sb/web/account/domain/InvestHolding.java create mode 100644 src/main/java/com/sb/web/account/domain/InvestTrade.java create mode 100644 src/main/java/com/sb/web/account/domain/Recurring.java create mode 100644 src/main/java/com/sb/web/account/domain/Wallet.java create mode 100644 src/main/java/com/sb/web/account/dto/AccountEntryRequest.java create mode 100644 src/main/java/com/sb/web/account/dto/AccountEntryResponse.java create mode 100644 src/main/java/com/sb/web/account/dto/AccountSummary.java create mode 100644 src/main/java/com/sb/web/account/dto/AccountTagRequest.java create mode 100644 src/main/java/com/sb/web/account/dto/AccountTagResponse.java create mode 100644 src/main/java/com/sb/web/account/dto/BudgetPeriodStat.java create mode 100644 src/main/java/com/sb/web/account/dto/BudgetRequest.java create mode 100644 src/main/java/com/sb/web/account/dto/BudgetResponse.java create mode 100644 src/main/java/com/sb/web/account/dto/BudgetStatus.java create mode 100644 src/main/java/com/sb/web/account/dto/CategoryRequest.java create mode 100644 src/main/java/com/sb/web/account/dto/CategoryResponse.java create mode 100644 src/main/java/com/sb/web/account/dto/CategoryStat.java create mode 100644 src/main/java/com/sb/web/account/dto/HoldingRequest.java create mode 100644 src/main/java/com/sb/web/account/dto/HoldingResponse.java create mode 100644 src/main/java/com/sb/web/account/dto/NetWorthPoint.java create mode 100644 src/main/java/com/sb/web/account/dto/NetWorthResponse.java create mode 100644 src/main/java/com/sb/web/account/dto/PeriodStat.java create mode 100644 src/main/java/com/sb/web/account/dto/RecurringRequest.java create mode 100644 src/main/java/com/sb/web/account/dto/RecurringResponse.java create mode 100644 src/main/java/com/sb/web/account/dto/RepaymentRequest.java create mode 100644 src/main/java/com/sb/web/account/dto/TradeRequest.java create mode 100644 src/main/java/com/sb/web/account/dto/TradeResponse.java create mode 100644 src/main/java/com/sb/web/account/dto/WalletRequest.java create mode 100644 src/main/java/com/sb/web/account/dto/WalletResponse.java create mode 100644 src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java create mode 100644 src/main/java/com/sb/web/account/mapper/AccountTagMapper.java create mode 100644 src/main/java/com/sb/web/account/mapper/BudgetMapper.java create mode 100644 src/main/java/com/sb/web/account/mapper/CategoryMapper.java create mode 100644 src/main/java/com/sb/web/account/mapper/InvestMapper.java create mode 100644 src/main/java/com/sb/web/account/mapper/RecurringMapper.java create mode 100644 src/main/java/com/sb/web/account/mapper/WalletMapper.java create mode 100644 src/main/java/com/sb/web/account/service/AccountService.java create mode 100644 src/main/java/com/sb/web/account/service/BudgetService.java create mode 100644 src/main/java/com/sb/web/account/service/CategoryService.java create mode 100644 src/main/java/com/sb/web/account/service/InvestService.java create mode 100644 src/main/java/com/sb/web/account/service/RecurringService.java create mode 100644 src/main/java/com/sb/web/admin/controller/AdminTagController.java create mode 100644 src/main/java/com/sb/web/auth/controller/AuthController.java create mode 100644 src/main/java/com/sb/web/auth/domain/Member.java create mode 100644 src/main/java/com/sb/web/auth/dto/LoginRequest.java create mode 100644 src/main/java/com/sb/web/auth/dto/LoginResponse.java create mode 100644 src/main/java/com/sb/web/auth/dto/MemberResponse.java create mode 100644 src/main/java/com/sb/web/auth/dto/SessionUser.java create mode 100644 src/main/java/com/sb/web/auth/dto/SignupRequest.java create mode 100644 src/main/java/com/sb/web/auth/mapper/MemberMapper.java create mode 100644 src/main/java/com/sb/web/auth/service/AuthService.java create mode 100644 src/main/java/com/sb/web/auth/web/AdminInterceptor.java create mode 100644 src/main/java/com/sb/web/auth/web/AuthInterceptor.java create mode 100644 src/main/java/com/sb/web/board/controller/BoardController.java create mode 100644 src/main/java/com/sb/web/board/domain/Comment.java create mode 100644 src/main/java/com/sb/web/board/domain/Post.java create mode 100644 src/main/java/com/sb/web/board/domain/Tag.java create mode 100644 src/main/java/com/sb/web/board/domain/TagCategory.java create mode 100644 src/main/java/com/sb/web/board/dto/BoardSettingRequest.java create mode 100644 src/main/java/com/sb/web/board/dto/BoardSettingResponse.java create mode 100644 src/main/java/com/sb/web/board/dto/CommentRequest.java create mode 100644 src/main/java/com/sb/web/board/dto/CommentResponse.java create mode 100644 src/main/java/com/sb/web/board/dto/PageResponse.java create mode 100644 src/main/java/com/sb/web/board/dto/PostDetail.java create mode 100644 src/main/java/com/sb/web/board/dto/PostRequest.java create mode 100644 src/main/java/com/sb/web/board/dto/PostSummary.java create mode 100644 src/main/java/com/sb/web/board/dto/TagCategoryRequest.java create mode 100644 src/main/java/com/sb/web/board/dto/TagCategoryResponse.java create mode 100644 src/main/java/com/sb/web/board/dto/TagRequest.java create mode 100644 src/main/java/com/sb/web/board/dto/TagResponse.java create mode 100644 src/main/java/com/sb/web/board/mapper/BoardSettingMapper.java create mode 100644 src/main/java/com/sb/web/board/mapper/CommentMapper.java create mode 100644 src/main/java/com/sb/web/board/mapper/PostMapper.java create mode 100644 src/main/java/com/sb/web/board/mapper/TagCategoryMapper.java create mode 100644 src/main/java/com/sb/web/board/mapper/TagMapper.java create mode 100644 src/main/java/com/sb/web/board/service/BoardService.java create mode 100644 src/main/java/com/sb/web/board/service/TagService.java rename src/main/java/com/{example/sb_bt => sb/web/common}/config/CorsConfig.java (96%) create mode 100644 src/main/java/com/sb/web/common/config/PasswordConfig.java rename src/main/java/com/{example/sb_bt => sb/web/common}/config/RedisConfig.java (53%) create mode 100644 src/main/java/com/sb/web/common/config/WebConfig.java rename src/main/java/com/{example/sb_bt => sb/web/common}/controller/HealthController.java (92%) create mode 100644 src/main/java/com/sb/web/common/exception/ApiException.java create mode 100644 src/main/java/com/sb/web/common/exception/GlobalExceptionHandler.java rename src/main/java/com/{example/sb_bt => sb/web/user}/controller/UserController.java (91%) rename src/main/java/com/{example/sb_bt => sb/web/user}/domain/User.java (93%) rename src/main/java/com/{example/sb_bt => sb/web/user}/mapper/UserMapper.java (84%) rename src/main/java/com/{example/sb_bt => sb/web/user}/service/UserService.java (93%) create mode 100644 src/main/resources/db/account.sql create mode 100644 src/main/resources/db/board.sql create mode 100644 src/main/resources/db/dev-seed.sql create mode 100644 src/main/resources/db/member.sql create mode 100644 src/main/resources/mapper/AccountEntryMapper.xml create mode 100644 src/main/resources/mapper/AccountTagMapper.xml create mode 100644 src/main/resources/mapper/BoardSettingMapper.xml create mode 100644 src/main/resources/mapper/BudgetMapper.xml create mode 100644 src/main/resources/mapper/CategoryMapper.xml create mode 100644 src/main/resources/mapper/CommentMapper.xml create mode 100644 src/main/resources/mapper/InvestMapper.xml create mode 100644 src/main/resources/mapper/MemberMapper.xml create mode 100644 src/main/resources/mapper/PostMapper.xml create mode 100644 src/main/resources/mapper/RecurringMapper.xml create mode 100644 src/main/resources/mapper/TagCategoryMapper.xml create mode 100644 src/main/resources/mapper/TagMapper.xml create mode 100644 src/main/resources/mapper/WalletMapper.xml rename src/test/java/com/{example/sb_bt => sb/web}/SbBtApplicationTests.java (86%) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..98c4ce8 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# DB 접속 정보 템플릿. 이 파일을 복사해 .env 로 만들고 실제 값으로 채우세요. +# cp .env.example .env +# .env 는 git 에 커밋되지 않습니다. +# --- MariaDB --- +DB_URL=jdbc:mariadb://:3306/sb_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul +DB_USERNAME= +DB_PASSWORD= + +# --- Redis --- +REDIS_HOST= +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DATABASE=0 diff --git a/.gitignore b/.gitignore index c2065bc..ac86434 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ HELP.md .gradle build/ + +# 환경변수 / 비밀 정보 (실제 .env 는 커밋 금지, .env.example 만 추적) +.env !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ diff --git a/build.gradle b/build.gradle index e0f7b8c..43e6087 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' } -group = 'com.example' +group = 'com.sb' version = '0.0.1-SNAPSHOT' java { @@ -22,6 +22,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5' + // 비밀번호 BCrypt 해싱 (전체 Spring Security 미도입, crypto 모듈만 사용) + implementation 'org.springframework.security:spring-security-crypto' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' annotationProcessor 'org.projectlombok:lombok' diff --git a/docs/BACKEND.md b/docs/BACKEND.md new file mode 100644 index 0000000..8d56309 --- /dev/null +++ b/docs/BACKEND.md @@ -0,0 +1,86 @@ +# Slim Budget — 백엔드 (sb_bt) + +Spring Boot 기반의 가계부·게시판 REST API 서버. + +## 기술 스택 +- **Spring Boot 3.5** / **Java 17** +- **MyBatis** (XML 매퍼) + **MariaDB** +- **Redis** (세션 저장) +- **spring-security-crypto** (BCrypt 비밀번호 해시) +- Lombok, Bean Validation + +## 패키지 구조 +``` +com.sb.web +├── account # 가계부 (controller / service / mapper / domain / dto) +├── auth # 인증 (로그인/세션, AuthInterceptor) +├── board # 게시판 +├── user # 사용자 +├── admin # 관리자 (태그 카테고리, 게시판 설정) +└── common # 공통 (예외, 응답, 설정) +``` + +## 스키마 자동 초기화 +`spring.sql.init.schema-locations` 로 기동 시 적용 (멱등 — `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 +``` +주요 테이블: `member`, `wallet`, `account_entry`, `account_tag`, `account_entry_tag`, +`account_category`, `budget`, `recurring`, `invest_holding`, `invest_trade`, +`post`, `comment`, `tag`, `tag_category` 등. + +## 인증 / 보안 +- 로그인 시 토큰 발급, 이후 `Authorization: Bearer ` 로 인증 +- `AuthInterceptor` 가 현재 사용자(`SessionUser`)를 요청 속성(`CURRENT_MEMBER`)으로 주입 +- **모든 가계부 데이터는 `member_id`(소유자)로 격리** — 본인 데이터만 조회/수정 +- DB 자격증명 등은 `.env`(gitignore)로 관리, `.env.example` 템플릿 제공 + +## REST API 개요 + +### 인증 · 사용자 · 관리자 +- `POST /api/auth/login` 외 인증 엔드포인트 +- `/api/users` — 사용자 관리 +- `/api/admin/**` — 태그 카테고리·게시판 설정(관리자) +- `/api/board/**` — 게시판(글/댓글/태그/열람제한) + +### 가계부 `/api/account` +| 메서드·경로 | 설명 | +|---|---| +| `GET/POST/PUT/DELETE /entries` | 내역 CRUD. 조회 필터: `year,month,type,category,walletId,keyword,tagId` | +| `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/**` | 투자 포트폴리오(아래) | + +### 잔액 · 순자산 모델 +- 부호 모델: 자산(+) / 부채(−) +- 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금 (`WalletMapper.findBalances`) +- 순자산 = Σ자산 − Σ부채. 투자계좌는 **예수금 + 주식 평가금액**으로 반영 +- 순자산 추이: 현금흐름 누적(이체는 상쇄) + 투자 손익은 현재 시점에 가산 + +### 정기 거래 (recurring) +- 빈도(DAILY/WEEKLY/MONTHLY/YEARLY) + 일/요일/월 지정 +- `last_run_date` 커서로 **중복 없이** 밀린 회차를 따라잡아 `account_entry` 생성 + +### 투자 포트폴리오 (`/api/account/invest`) — 종목 단위(C) +| 경로 | 설명 | +|---|---| +| `GET/POST/PUT/DELETE /holdings` | 보유 종목 (종목명·코드·현재가). 집계: 수량·평단·평가금액·평가손익·수익률·실현손익 | +| `GET/POST /holdings/{id}/trades`, `DELETE /trades/{id}` | 매수/매도 이력 | +- 매매이력(시간순)으로 **이동평균 평단**·실현손익·평가손익 산출 +- 매수 시 평단 재계산 `(기존수량×평단 + 매수수량×단가 + 수수료)/총수량`, 매도 시 실현손익 = (매도가 − 평단)×수량 − 수수료 +- 매도수량이 보유수량 초과 시 400 반환 +- 투자계좌 가치 = 예수금(현금 ± 매매) + 주식평가, 순자산/추이에 연동 +- 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외) + +## 개발/실행 +```bash +cp .env.example .env # DB/Redis 자격증명 입력 +./gradlew bootRun # http://localhost:8080 +``` diff --git a/src/main/java/com/example/sb_bt/SbBtApplication.java b/src/main/java/com/sb/web/SbBtApplication.java similarity index 69% rename from src/main/java/com/example/sb_bt/SbBtApplication.java rename to src/main/java/com/sb/web/SbBtApplication.java index 44119bc..3237cd7 100644 --- a/src/main/java/com/example/sb_bt/SbBtApplication.java +++ b/src/main/java/com/sb/web/SbBtApplication.java @@ -1,11 +1,11 @@ -package com.example.sb_bt; +package com.sb.web; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -@MapperScan("com.example.sb_bt.mapper") +@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper"}) public class SbBtApplication { public static void main(String[] args) { diff --git a/src/main/java/com/sb/web/account/controller/AccountController.java b/src/main/java/com/sb/web/account/controller/AccountController.java new file mode 100644 index 0000000..260e072 --- /dev/null +++ b/src/main/java/com/sb/web/account/controller/AccountController.java @@ -0,0 +1,196 @@ +package com.sb.web.account.controller; + +import com.sb.web.account.dto.AccountEntryRequest; +import com.sb.web.account.dto.AccountEntryResponse; +import com.sb.web.account.dto.AccountSummary; +import com.sb.web.account.dto.AccountTagRequest; +import com.sb.web.account.dto.AccountTagResponse; +import com.sb.web.account.dto.CategoryStat; +import com.sb.web.account.dto.NetWorthPoint; +import com.sb.web.account.dto.NetWorthResponse; +import com.sb.web.account.dto.PeriodStat; +import com.sb.web.account.dto.RepaymentRequest; +import com.sb.web.account.dto.WalletRequest; +import com.sb.web.account.dto.WalletResponse; +import com.sb.web.account.service.AccountService; +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.web.AuthInterceptor; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 가계부 API. (/api/account/** — 로그인 필요, 본인 데이터만 접근) + * GET /entries?year=&month= 내역 목록(본인) + * GET /summary?year=&month= 요약(수입/지출/잔액) + * POST /entries 등록 + * PUT /entries/{id} 수정(본인) + * DELETE /entries/{id} 삭제(본인) + */ +@RestController +@RequestMapping("/api/account") +@RequiredArgsConstructor +public class AccountController { + + private final AccountService accountService; + + /* ===== 계좌/카드 (사용자별) ===== */ + + @GetMapping("/wallets") + public List wallets( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.listWallets(current.getId()); + } + + @PostMapping("/wallets") + public ResponseEntity createWallet( + @Valid @RequestBody WalletRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return ResponseEntity.status(HttpStatus.CREATED).body(accountService.createWallet(req, current.getId())); + } + + @PutMapping("/wallets/{id}") + public WalletResponse updateWallet( + @PathVariable Long id, + @Valid @RequestBody WalletRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.updateWallet(id, req, current.getId()); + } + + @DeleteMapping("/wallets/{id}") + public ResponseEntity deleteWallet( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + accountService.deleteWallet(id, current.getId()); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/networth") + public NetWorthResponse netWorth( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.netWorth(current.getId()); + } + + /** 특정 계좌의 연관 내역 */ + @GetMapping("/wallets/{id}/entries") + public List walletEntries( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.listEntriesByWallet(id, current.getId()); + } + + /** 대출/카드 상환·납부 (원금=이체, 이자=지출 자동 분리) */ + @PostMapping("/repayment") + public ResponseEntity repay( + @Valid @RequestBody RepaymentRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + accountService.repay(req, current.getId()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + /* ===== 가계부 태그 (사용자별) ===== */ + + @GetMapping("/tags") + public List tags( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.listTags(current.getId()); + } + + @PostMapping("/tags") + public ResponseEntity createTag( + @Valid @RequestBody AccountTagRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return ResponseEntity.status(HttpStatus.CREATED).body(accountService.createTag(req, current.getId())); + } + + @PutMapping("/tags/{id}") + public AccountTagResponse updateTag( + @PathVariable Long id, + @Valid @RequestBody AccountTagRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.updateTag(id, req, current.getId()); + } + + @DeleteMapping("/tags/{id}") + public ResponseEntity deleteTag( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + accountService.deleteTag(id, current.getId()); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/entries") + public List list( + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Integer month, + @RequestParam(required = false) String type, + @RequestParam(required = false) String category, + @RequestParam(required = false) Long walletId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Long tagId, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.list(current.getId(), year, month, type, category, walletId, keyword, tagId); + } + + /** 분류(카테고리)별 합계 — 파이차트용 */ + @GetMapping("/category-stats") + public List categoryStats( + @RequestParam(defaultValue = "EXPENSE") String type, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Integer month, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.categoryStats(current.getId(), type, year, month); + } + + /** 월별 순자산 추이 */ + @GetMapping("/networth/trend") + public List netWorthTrend( + @RequestParam(required = false) Integer months, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.netWorthTrend(current.getId(), months); + } + + @GetMapping("/stats") + public List stats( + @RequestParam(defaultValue = "MONTH") String unit, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Integer month, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.stats(current.getId(), unit, year, month); + } + + @GetMapping("/summary") + public AccountSummary summary( + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Integer month, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.summary(current.getId(), year, month); + } + + @PostMapping("/entries") + public ResponseEntity create( + @Valid @RequestBody AccountEntryRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return ResponseEntity.status(HttpStatus.CREATED).body(accountService.create(req, current.getId())); + } + + @PutMapping("/entries/{id}") + public AccountEntryResponse update( + @PathVariable Long id, + @Valid @RequestBody AccountEntryRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.update(id, req, current.getId()); + } + + @DeleteMapping("/entries/{id}") + public ResponseEntity delete( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + accountService.delete(id, current.getId()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sb/web/account/controller/BudgetController.java b/src/main/java/com/sb/web/account/controller/BudgetController.java new file mode 100644 index 0000000..8589fa5 --- /dev/null +++ b/src/main/java/com/sb/web/account/controller/BudgetController.java @@ -0,0 +1,74 @@ +package com.sb.web.account.controller; + +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.service.BudgetService; +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.web.AuthInterceptor; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 예산 API. (/api/account/budgets — 로그인 필요, 본인 데이터만) + */ +@RestController +@RequestMapping("/api/account/budgets") +@RequiredArgsConstructor +public class BudgetController { + + private final BudgetService budgetService; + + @GetMapping + public List list( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return budgetService.list(current.getId()); + } + + @GetMapping("/status") + public List status( + @RequestParam int year, + @RequestParam int month, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return budgetService.status(current.getId(), year, month); + } + + /** 기간(일/주/월/년)별 예산 대비 지출 (대시보드 막대그래프) */ + @GetMapping("/period") + public List period( + @RequestParam(defaultValue = "MONTH") String unit, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Integer month, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return budgetService.periodStats(current.getId(), unit, year, month); + } + + @PostMapping + public ResponseEntity create( + @Valid @RequestBody BudgetRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return ResponseEntity.status(HttpStatus.CREATED).body(budgetService.create(req, current.getId())); + } + + @PutMapping("/{id}") + public BudgetResponse update( + @PathVariable Long id, + @Valid @RequestBody BudgetRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return budgetService.update(id, req, current.getId()); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + budgetService.delete(id, current.getId()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sb/web/account/controller/CategoryController.java b/src/main/java/com/sb/web/account/controller/CategoryController.java new file mode 100644 index 0000000..59ce75a --- /dev/null +++ b/src/main/java/com/sb/web/account/controller/CategoryController.java @@ -0,0 +1,61 @@ +package com.sb.web.account.controller; + +import com.sb.web.account.dto.CategoryRequest; +import com.sb.web.account.dto.CategoryResponse; +import com.sb.web.account.service.CategoryService; +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.web.AuthInterceptor; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 분류(카테고리) API. (/api/account/categories — 로그인 필요, 본인 데이터만) + */ +@RestController +@RequestMapping("/api/account/categories") +@RequiredArgsConstructor +public class CategoryController { + + private final CategoryService categoryService; + + @GetMapping + public List list( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return categoryService.list(current.getId()); + } + + @PostMapping + public ResponseEntity create( + @Valid @RequestBody CategoryRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return ResponseEntity.status(HttpStatus.CREATED).body(categoryService.create(req, current.getId())); + } + + @PutMapping("/{id}") + public CategoryResponse update( + @PathVariable Long id, + @Valid @RequestBody CategoryRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return categoryService.update(id, req, current.getId()); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + categoryService.delete(id, current.getId()); + return ResponseEntity.noContent().build(); + } + + /** 기존 내역의 분류를 목록으로 가져오기 */ + @PostMapping("/import") + public List importFromEntries( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return categoryService.importFromEntries(current.getId()); + } +} diff --git a/src/main/java/com/sb/web/account/controller/InvestController.java b/src/main/java/com/sb/web/account/controller/InvestController.java new file mode 100644 index 0000000..c4d0fed --- /dev/null +++ b/src/main/java/com/sb/web/account/controller/InvestController.java @@ -0,0 +1,87 @@ +package com.sb.web.account.controller; + +import com.sb.web.account.dto.HoldingRequest; +import com.sb.web.account.dto.HoldingResponse; +import com.sb.web.account.dto.TradeRequest; +import com.sb.web.account.dto.TradeResponse; +import com.sb.web.account.service.InvestService; +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.web.AuthInterceptor; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 투자 포트폴리오 API (C). 로그인 필요, 본인 데이터만. + * GET /api/account/invest/holdings?walletId= 보유종목(집계 포함) + * POST /api/account/invest/holdings 종목 추가 + * PUT /api/account/invest/holdings/{id} 종목 수정(현재가 등) + * DELETE /api/account/invest/holdings/{id} 종목 삭제(매매이력 포함) + * GET /api/account/invest/holdings/{id}/trades 매매이력 + * POST /api/account/invest/holdings/{id}/trades 매수/매도 + * DELETE /api/account/invest/trades/{id} 매매 삭제 + */ +@RestController +@RequestMapping("/api/account/invest") +@RequiredArgsConstructor +public class InvestController { + + private final InvestService investService; + + @GetMapping("/holdings") + public List holdings( + @RequestParam Long walletId, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return investService.listHoldings(current.getId(), walletId); + } + + @PostMapping("/holdings") + public ResponseEntity createHolding( + @Valid @RequestBody HoldingRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return ResponseEntity.status(HttpStatus.CREATED).body(investService.createHolding(req, current.getId())); + } + + @PutMapping("/holdings/{id}") + public HoldingResponse updateHolding( + @PathVariable Long id, + @Valid @RequestBody HoldingRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return investService.updateHolding(id, req, current.getId()); + } + + @DeleteMapping("/holdings/{id}") + public ResponseEntity deleteHolding( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + investService.deleteHolding(id, current.getId()); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/holdings/{id}/trades") + public List trades( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return investService.listTrades(id, current.getId()); + } + + @PostMapping("/holdings/{id}/trades") + public ResponseEntity addTrade( + @PathVariable Long id, + @Valid @RequestBody TradeRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return ResponseEntity.status(HttpStatus.CREATED).body(investService.addTrade(id, req, current.getId())); + } + + @DeleteMapping("/trades/{id}") + public ResponseEntity deleteTrade( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + investService.deleteTrade(id, current.getId()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sb/web/account/controller/RecurringController.java b/src/main/java/com/sb/web/account/controller/RecurringController.java new file mode 100644 index 0000000..2a42458 --- /dev/null +++ b/src/main/java/com/sb/web/account/controller/RecurringController.java @@ -0,0 +1,62 @@ +package com.sb.web.account.controller; + +import com.sb.web.account.dto.RecurringRequest; +import com.sb.web.account.dto.RecurringResponse; +import com.sb.web.account.service.RecurringService; +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.web.AuthInterceptor; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 정기/반복 거래 API. (/api/account/recurrings — 로그인 필요, 본인 데이터만) + */ +@RestController +@RequestMapping("/api/account/recurrings") +@RequiredArgsConstructor +public class RecurringController { + + private final RecurringService recurringService; + + @GetMapping + public List list( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return recurringService.list(current.getId()); + } + + @PostMapping + public ResponseEntity create( + @Valid @RequestBody RecurringRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return ResponseEntity.status(HttpStatus.CREATED).body(recurringService.create(req, current.getId())); + } + + @PutMapping("/{id}") + public RecurringResponse update( + @PathVariable Long id, + @Valid @RequestBody RecurringRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return recurringService.update(id, req, current.getId()); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + recurringService.delete(id, current.getId()); + return ResponseEntity.noContent().build(); + } + + /** 밀린 회차 자동 생성 → 생성 건수 반환 */ + @PostMapping("/run") + public Map run( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return Map.of("generated", recurringService.run(current.getId())); + } +} diff --git a/src/main/java/com/sb/web/account/domain/AccountCategory.java b/src/main/java/com/sb/web/account/domain/AccountCategory.java new file mode 100644 index 0000000..f124e53 --- /dev/null +++ b/src/main/java/com/sb/web/account/domain/AccountCategory.java @@ -0,0 +1,26 @@ +package com.sb.web.account.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 분류(카테고리) — 사용자별, 수입(INCOME)/지출(EXPENSE). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AccountCategory implements Serializable { + + private Long id; + private Long memberId; + private String type; // INCOME / EXPENSE + private String name; + private Integer sortOrder; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sb/web/account/domain/AccountEntry.java b/src/main/java/com/sb/web/account/domain/AccountEntry.java new file mode 100644 index 0000000..22ec4cc --- /dev/null +++ b/src/main/java/com/sb/web/account/domain/AccountEntry.java @@ -0,0 +1,34 @@ +package com.sb.web.account.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 가계부 항목. account_entry 테이블과 매핑. (소유자 member_id 격리) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AccountEntry implements Serializable { + + private Long id; + private Long memberId; + private LocalDate entryDate; + private String type; // INCOME / EXPENSE + private String category; + private Long amount; // 원(양수) + private String memo; + private Long walletId; // 발생/출금 계좌(wallet.id) + private String walletName; // 조인 표시용 + private Long toWalletId; // 이체 입금 계좌 + private String toWalletName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/sb/web/account/domain/AccountTag.java b/src/main/java/com/sb/web/account/domain/AccountTag.java new file mode 100644 index 0000000..93c36fa --- /dev/null +++ b/src/main/java/com/sb/web/account/domain/AccountTag.java @@ -0,0 +1,24 @@ +package com.sb.web.account.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 가계부 태그 (사용자별). account_tag 테이블과 매핑. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AccountTag implements Serializable { + + private Long id; + private Long memberId; + private String name; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sb/web/account/domain/Budget.java b/src/main/java/com/sb/web/account/domain/Budget.java new file mode 100644 index 0000000..9e912d7 --- /dev/null +++ b/src/main/java/com/sb/web/account/domain/Budget.java @@ -0,0 +1,33 @@ +package com.sb.web.account.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 예산 (사용자별, 카테고리별). budget 테이블과 매핑. + * fixed=true: base_unit/base_amount 로 일수기준 환산. false: daily/weekly/monthly/yearly 직접. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Budget implements Serializable { + + private Long id; + private Long memberId; + private String category; + private Boolean fixed; + private String baseUnit; // DAY / WEEK / MONTH + private Long baseAmount; + private Long daily; + private Long weekly; + private Long monthly; + private Long yearly; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/sb/web/account/domain/InvestHolding.java b/src/main/java/com/sb/web/account/domain/InvestHolding.java new file mode 100644 index 0000000..dc355fe --- /dev/null +++ b/src/main/java/com/sb/web/account/domain/InvestHolding.java @@ -0,0 +1,28 @@ +package com.sb.web.account.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 투자 보유 종목 (INVEST 지갑별, 사용자별). invest_holding 테이블과 매핑. + * 수량/평단/평가손익은 매매이력(invest_trade)으로 서비스에서 산출한다. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InvestHolding { + + private Long id; + private Long memberId; + private Long walletId; + private String name; // 종목명 + private String ticker; // 종목코드(선택) + private Long currentPrice; // 현재가(원, 수동) + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/sb/web/account/domain/InvestTrade.java b/src/main/java/com/sb/web/account/domain/InvestTrade.java new file mode 100644 index 0000000..2899243 --- /dev/null +++ b/src/main/java/com/sb/web/account/domain/InvestTrade.java @@ -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; + +/** + * 투자 매매 이력(매수/매도). invest_trade 테이블과 매핑. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InvestTrade { + + private Long id; + private Long memberId; + private Long holdingId; + private String tradeType; // BUY / SELL + private LocalDate tradeDate; + private Long quantity; // 수량(주) + private Long price; // 단가(원) + private Long fee; // 수수료/세금(원) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sb/web/account/domain/Recurring.java b/src/main/java/com/sb/web/account/domain/Recurring.java new file mode 100644 index 0000000..ef72df2 --- /dev/null +++ b/src/main/java/com/sb/web/account/domain/Recurring.java @@ -0,0 +1,42 @@ +package com.sb.web.account.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 정기/반복 거래 규칙. recurring 테이블과 매핑. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Recurring implements Serializable { + + private Long id; + private Long memberId; + private String title; + private String type; // INCOME / EXPENSE / TRANSFER + private Long amount; + private String category; + private String memo; + private Long walletId; + private String walletName; // 조인 표시용 + private Long toWalletId; + private String toWalletName; // 조인 표시용 + private String frequency; // DAILY / WEEKLY / MONTHLY / YEARLY + private Integer dayOfMonth; + private Integer dayOfWeek; // 1=월 ~ 7=일 + private Integer monthOfYear; + private LocalDate startDate; + private LocalDate endDate; + private LocalDate lastRunDate; + private Boolean active; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/sb/web/account/domain/Wallet.java b/src/main/java/com/sb/web/account/domain/Wallet.java new file mode 100644 index 0000000..a7a1ff5 --- /dev/null +++ b/src/main/java/com/sb/web/account/domain/Wallet.java @@ -0,0 +1,34 @@ +package com.sb.web.account.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 계좌/카드/대출/투자 (사용자별). wallet 테이블과 매핑. + * type=BANK(은행계좌) / CARD(카드) / LOAN(대출) / INVEST(투자). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Wallet implements Serializable { + + private Long id; + private Long memberId; + private String type; // BANK / CARD + private String name; // 별칭 + private String issuer; // 은행명 / 카드사 + private String accountNumber; // 계좌번호 (BANK) + private String cardType; // CREDIT / CHECK (CARD) + private Long openingBalance; // 초기 잔액(부호있음: 자산+, 부채-) + private LocalDate openingDate; + private Long currentValue; // 현재 평가금액 (INVEST 전용, 수동 갱신) + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/sb/web/account/dto/AccountEntryRequest.java b/src/main/java/com/sb/web/account/dto/AccountEntryRequest.java new file mode 100644 index 0000000..3030abe --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/AccountEntryRequest.java @@ -0,0 +1,44 @@ +package com.sb.web.account.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +/** + * 가계부 항목 생성/수정 요청. + */ +@Data +public class AccountEntryRequest { + + @NotNull(message = "거래일을 입력하세요.") + private LocalDate entryDate; + + @NotBlank(message = "구분을 선택하세요.") + @Pattern(regexp = "INCOME|EXPENSE|TRANSFER", message = "구분이 올바르지 않습니다.") + private String type; + + @Size(max = 50, message = "분류는 50자 이내여야 합니다.") + private String category; + + @NotNull(message = "금액을 입력하세요.") + @PositiveOrZero(message = "금액은 0 이상이어야 합니다.") + private Long amount; + + @Size(max = 255, message = "메모는 255자 이내여야 합니다.") + private String memo; + + /** 발생/출금 계좌 ID (본인 wallet, 선택. 이체 시 출금 계좌) */ + private Long walletId; + + /** 이체 입금 계좌 ID (TRANSFER 시 필수) */ + private Long toWalletId; + + /** 선택한 태그 ID 목록 (태그 관리에 등록된 태그, 선택) */ + private List tagIds; +} diff --git a/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java b/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java new file mode 100644 index 0000000..c542a49 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java @@ -0,0 +1,44 @@ +package com.sb.web.account.dto; + +import com.sb.web.account.domain.AccountEntry; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +/** + * 가계부 항목 응답 (소유자 ID 제외, 태그 이름 포함). + */ +@Data +@Builder +public class AccountEntryResponse { + + private Long id; + private LocalDate entryDate; + private String type; + private String category; + private Long amount; + private String memo; + private Long walletId; + private String walletName; + private Long toWalletId; + private String toWalletName; + private List tags; + + public static AccountEntryResponse from(AccountEntry e, List tags) { + return AccountEntryResponse.builder() + .id(e.getId()) + .entryDate(e.getEntryDate()) + .type(e.getType()) + .category(e.getCategory()) + .amount(e.getAmount()) + .memo(e.getMemo()) + .walletId(e.getWalletId()) + .walletName(e.getWalletName()) + .toWalletId(e.getToWalletId()) + .toWalletName(e.getToWalletName()) + .tags(tags) + .build(); + } +} diff --git a/src/main/java/com/sb/web/account/dto/AccountSummary.java b/src/main/java/com/sb/web/account/dto/AccountSummary.java new file mode 100644 index 0000000..f60c965 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/AccountSummary.java @@ -0,0 +1,24 @@ +package com.sb.web.account.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 가계부 요약 (수입/지출/잔액). + */ +@Data +@Builder +public class AccountSummary { + + private long totalIncome; + private long totalExpense; + private long balance; + + public static AccountSummary of(long income, long expense) { + return AccountSummary.builder() + .totalIncome(income) + .totalExpense(expense) + .balance(income - expense) + .build(); + } +} diff --git a/src/main/java/com/sb/web/account/dto/AccountTagRequest.java b/src/main/java/com/sb/web/account/dto/AccountTagRequest.java new file mode 100644 index 0000000..e6d1023 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/AccountTagRequest.java @@ -0,0 +1,16 @@ +package com.sb.web.account.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 가계부 태그 생성/수정 요청. + */ +@Data +public class AccountTagRequest { + + @NotBlank(message = "태그 이름을 입력하세요.") + @Size(max = 50, message = "태그 이름은 50자 이내여야 합니다.") + private String name; +} diff --git a/src/main/java/com/sb/web/account/dto/AccountTagResponse.java b/src/main/java/com/sb/web/account/dto/AccountTagResponse.java new file mode 100644 index 0000000..ec25b56 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/AccountTagResponse.java @@ -0,0 +1,20 @@ +package com.sb.web.account.dto; + +import com.sb.web.account.domain.AccountTag; +import lombok.Builder; +import lombok.Data; + +/** + * 가계부 태그 응답. + */ +@Data +@Builder +public class AccountTagResponse { + + private Long id; + private String name; + + public static AccountTagResponse from(AccountTag t) { + return AccountTagResponse.builder().id(t.getId()).name(t.getName()).build(); + } +} diff --git a/src/main/java/com/sb/web/account/dto/BudgetPeriodStat.java b/src/main/java/com/sb/web/account/dto/BudgetPeriodStat.java new file mode 100644 index 0000000..9d379b4 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/BudgetPeriodStat.java @@ -0,0 +1,16 @@ +package com.sb.web.account.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 기간 버킷별 예산 대비 지출. + */ +@Data +@Builder +public class BudgetPeriodStat { + + private String bucket; + private long budget; + private long expense; +} diff --git a/src/main/java/com/sb/web/account/dto/BudgetRequest.java b/src/main/java/com/sb/web/account/dto/BudgetRequest.java new file mode 100644 index 0000000..80ba452 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/BudgetRequest.java @@ -0,0 +1,30 @@ +package com.sb.web.account.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 예산 생성/수정 요청. + */ +@Data +public class BudgetRequest { + + @NotBlank(message = "카테고리를 입력하세요.") + @Size(max = 50, message = "카테고리는 50자 이내여야 합니다.") + private String category; + + private boolean fixed; + + // 고정(fixed=true) + @Pattern(regexp = "DAY|WEEK|MONTH|", message = "기준 단위가 올바르지 않습니다.") + private String baseUnit; + private Long baseAmount; + + // 비고정(fixed=false) + private Long daily; + private Long weekly; + private Long monthly; + private Long yearly; +} diff --git a/src/main/java/com/sb/web/account/dto/BudgetResponse.java b/src/main/java/com/sb/web/account/dto/BudgetResponse.java new file mode 100644 index 0000000..a3ed2d3 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/BudgetResponse.java @@ -0,0 +1,37 @@ +package com.sb.web.account.dto; + +import com.sb.web.account.domain.Budget; +import lombok.Builder; +import lombok.Data; + +/** + * 예산 응답 (편집용). + */ +@Data +@Builder +public class BudgetResponse { + + private Long id; + private String category; + private boolean fixed; + private String baseUnit; + private Long baseAmount; + private Long daily; + private Long weekly; + private Long monthly; + private Long yearly; + + public static BudgetResponse from(Budget b) { + return BudgetResponse.builder() + .id(b.getId()) + .category(b.getCategory()) + .fixed(Boolean.TRUE.equals(b.getFixed())) + .baseUnit(b.getBaseUnit()) + .baseAmount(b.getBaseAmount()) + .daily(b.getDaily()) + .weekly(b.getWeekly()) + .monthly(b.getMonthly()) + .yearly(b.getYearly()) + .build(); + } +} diff --git a/src/main/java/com/sb/web/account/dto/BudgetStatus.java b/src/main/java/com/sb/web/account/dto/BudgetStatus.java new file mode 100644 index 0000000..b32d08d --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/BudgetStatus.java @@ -0,0 +1,19 @@ +package com.sb.web.account.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 특정 월의 예산 대비 지출 현황. + */ +@Data +@Builder +public class BudgetStatus { + + private Long id; + private String category; + private boolean fixed; + private long monthlyBudget; // 이 달의 예산(고정은 실제 일수 반영) + private long spent; // 이 달 지출 + private long remaining; // 잔여 +} diff --git a/src/main/java/com/sb/web/account/dto/CategoryRequest.java b/src/main/java/com/sb/web/account/dto/CategoryRequest.java new file mode 100644 index 0000000..21bb17d --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/CategoryRequest.java @@ -0,0 +1,21 @@ +package com.sb.web.account.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 분류 생성/수정 요청. + */ +@Data +public class CategoryRequest { + + @NotBlank(message = "유형을 선택하세요.") + @Pattern(regexp = "INCOME|EXPENSE", message = "유형이 올바르지 않습니다.") + private String type; + + @NotBlank(message = "분류 이름을 입력하세요.") + @Size(max = 50, message = "분류 이름은 50자 이내여야 합니다.") + private String name; +} diff --git a/src/main/java/com/sb/web/account/dto/CategoryResponse.java b/src/main/java/com/sb/web/account/dto/CategoryResponse.java new file mode 100644 index 0000000..73eb086 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/CategoryResponse.java @@ -0,0 +1,21 @@ +package com.sb.web.account.dto; + +import com.sb.web.account.domain.AccountCategory; +import lombok.Builder; +import lombok.Data; + +/** + * 분류 응답. + */ +@Data +@Builder +public class CategoryResponse { + + private Long id; + private String type; + private String name; + + public static CategoryResponse from(AccountCategory c) { + return CategoryResponse.builder().id(c.getId()).type(c.getType()).name(c.getName()).build(); + } +} diff --git a/src/main/java/com/sb/web/account/dto/CategoryStat.java b/src/main/java/com/sb/web/account/dto/CategoryStat.java new file mode 100644 index 0000000..8361688 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/CategoryStat.java @@ -0,0 +1,15 @@ +package com.sb.web.account.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 분류(카테고리)별 합계 통계. (파이차트용) + */ +@Data +@Builder +public class CategoryStat { + + private String category; // 분류명 (없으면 "미분류") + private long total; // 합계 금액 +} diff --git a/src/main/java/com/sb/web/account/dto/HoldingRequest.java b/src/main/java/com/sb/web/account/dto/HoldingRequest.java new file mode 100644 index 0000000..c9748c9 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/HoldingRequest.java @@ -0,0 +1,28 @@ +package com.sb.web.account.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 보유 종목 생성/수정 요청. + */ +@Data +public class HoldingRequest { + + @NotNull(message = "투자계좌를 선택하세요.") + private Long walletId; + + @NotBlank(message = "종목명을 입력하세요.") + @Size(max = 100, message = "종목명은 100자 이내여야 합니다.") + private String name; + + @Size(max = 20, message = "종목코드는 20자 이내여야 합니다.") + private String ticker; + + /** 현재가(원, 수동 갱신). 미입력 시 평단 기준 평가 */ + @PositiveOrZero(message = "현재가는 0 이상이어야 합니다.") + private Long currentPrice; +} diff --git a/src/main/java/com/sb/web/account/dto/HoldingResponse.java b/src/main/java/com/sb/web/account/dto/HoldingResponse.java new file mode 100644 index 0000000..bbaca70 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/HoldingResponse.java @@ -0,0 +1,26 @@ +package com.sb.web.account.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 보유 종목 응답 (매매이력으로 산출한 집계 포함). + */ +@Data +@Builder +public class HoldingResponse { + + private Long id; + private Long walletId; + private String name; + private String ticker; + private Long currentPrice; // 현재가(수동, null 가능) + + private Long quantity; // 보유수량 (매수−매도) + private Long avgPrice; // 평균매입단가 (보유분, 반올림 표시값) + private Long costBasis; // 보유분 매입원가 합(수수료 포함) + private Long evalValue; // 평가금액 = 수량 × (현재가 ?? 평단) + private Long evalGain; // 평가손익(미실현) = 평가금액 − 매입원가 + private Double returnPct; // 수익률(%) = 평가손익 / 매입원가 + private Long realizedPL; // 실현손익(매도 누적) +} diff --git a/src/main/java/com/sb/web/account/dto/NetWorthPoint.java b/src/main/java/com/sb/web/account/dto/NetWorthPoint.java new file mode 100644 index 0000000..f864981 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/NetWorthPoint.java @@ -0,0 +1,15 @@ +package com.sb.web.account.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 월별 순자산 추이 한 점. (순자산 = 모든 계좌 잔액 합, 부채는 음수) + */ +@Data +@Builder +public class NetWorthPoint { + + private String month; // "yyyy-MM" + private long netWorth; // 해당 월말 기준 순자산 +} diff --git a/src/main/java/com/sb/web/account/dto/NetWorthResponse.java b/src/main/java/com/sb/web/account/dto/NetWorthResponse.java new file mode 100644 index 0000000..b60bac7 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/NetWorthResponse.java @@ -0,0 +1,16 @@ +package com.sb.web.account.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 순자산 요약. totalAssets(총자산) - totalLiabilities(총부채) = netWorth. + */ +@Data +@Builder +public class NetWorthResponse { + + private long totalAssets; + private long totalLiabilities; + private long netWorth; +} diff --git a/src/main/java/com/sb/web/account/dto/PeriodStat.java b/src/main/java/com/sb/web/account/dto/PeriodStat.java new file mode 100644 index 0000000..b88e501 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/PeriodStat.java @@ -0,0 +1,16 @@ +package com.sb.web.account.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 기간 버킷별 수입/지출 통계. + */ +@Data +@Builder +public class PeriodStat { + + private String bucket; // 일/주/월/년 라벨 (예: "5", "1", "2026") + private long income; + private long expense; +} diff --git a/src/main/java/com/sb/web/account/dto/RecurringRequest.java b/src/main/java/com/sb/web/account/dto/RecurringRequest.java new file mode 100644 index 0000000..11e22e0 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/RecurringRequest.java @@ -0,0 +1,52 @@ +package com.sb.web.account.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 정기 거래 생성/수정 요청. + */ +@Data +public class RecurringRequest { + + @NotBlank(message = "이름을 입력하세요.") + @Size(max = 100, message = "이름은 100자 이내여야 합니다.") + private String title; + + @NotBlank + @Pattern(regexp = "INCOME|EXPENSE|TRANSFER", message = "구분이 올바르지 않습니다.") + private String type; + + @NotNull(message = "금액을 입력하세요.") + @Positive(message = "금액은 0보다 커야 합니다.") + private Long amount; + + @Size(max = 50) + private String category; + + @Size(max = 255) + private String memo; + + private Long walletId; + private Long toWalletId; + + @NotBlank + @Pattern(regexp = "DAILY|WEEKLY|MONTHLY|YEARLY", message = "주기가 올바르지 않습니다.") + private String frequency; + + private Integer dayOfMonth; // MONTHLY/YEARLY + private Integer dayOfWeek; // WEEKLY (1~7) + private Integer monthOfYear; // YEARLY (1~12) + + @NotNull(message = "시작일을 입력하세요.") + private LocalDate startDate; + private LocalDate endDate; + + private boolean active = true; +} diff --git a/src/main/java/com/sb/web/account/dto/RecurringResponse.java b/src/main/java/com/sb/web/account/dto/RecurringResponse.java new file mode 100644 index 0000000..264f706 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/RecurringResponse.java @@ -0,0 +1,35 @@ +package com.sb.web.account.dto; + +import com.sb.web.account.domain.Recurring; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 정기 거래 응답 (다음 예정일 포함). + */ +@Data +@Builder +public class RecurringResponse { + + private Long id; + private String title; + private String type; + private Long amount; + private String category; + private String memo; + private Long walletId; + private String walletName; + private Long toWalletId; + private String toWalletName; + private String frequency; + private Integer dayOfMonth; + private Integer dayOfWeek; + private Integer monthOfYear; + private LocalDate startDate; + private LocalDate endDate; + private LocalDate lastRunDate; + private LocalDate nextDate; // 다음 예정일(계산) + private boolean active; +} diff --git a/src/main/java/com/sb/web/account/dto/RepaymentRequest.java b/src/main/java/com/sb/web/account/dto/RepaymentRequest.java new file mode 100644 index 0000000..bd984e9 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/RepaymentRequest.java @@ -0,0 +1,33 @@ +package com.sb.web.account.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 대출/카드 상환·납부 요청. 원금은 이체, 이자는 지출로 분리 생성된다. + */ +@Data +public class RepaymentRequest { + + @NotNull(message = "거래일을 입력하세요.") + private LocalDate entryDate; + + @NotNull(message = "출금 계좌를 선택하세요.") + private Long fromWalletId; + + @NotNull(message = "대상(대출/카드)을 선택하세요.") + private Long targetWalletId; + + @PositiveOrZero(message = "원금은 0 이상이어야 합니다.") + private Long principal; + + @PositiveOrZero(message = "이자는 0 이상이어야 합니다.") + private Long interest; + + @Size(max = 255, message = "메모는 255자 이내여야 합니다.") + private String memo; +} diff --git a/src/main/java/com/sb/web/account/dto/TradeRequest.java b/src/main/java/com/sb/web/account/dto/TradeRequest.java new file mode 100644 index 0000000..b6f2040 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/TradeRequest.java @@ -0,0 +1,32 @@ +package com.sb.web.account.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 매매(매수/매도) 등록 요청. + */ +@Data +public class TradeRequest { + + @Pattern(regexp = "BUY|SELL", message = "매매 구분이 올바르지 않습니다.") + private String tradeType; + + @NotNull(message = "거래일을 입력하세요.") + private LocalDate tradeDate; + + @Positive(message = "수량은 1 이상이어야 합니다.") + private Long quantity; + + @PositiveOrZero(message = "단가는 0 이상이어야 합니다.") + private Long price; + + /** 수수료/세금(원). 미입력 시 0 */ + @PositiveOrZero(message = "수수료는 0 이상이어야 합니다.") + private Long fee; +} diff --git a/src/main/java/com/sb/web/account/dto/TradeResponse.java b/src/main/java/com/sb/web/account/dto/TradeResponse.java new file mode 100644 index 0000000..9d73a71 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/TradeResponse.java @@ -0,0 +1,37 @@ +package com.sb.web.account.dto; + +import com.sb.web.account.domain.InvestTrade; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 매매 이력 응답. + */ +@Data +@Builder +public class TradeResponse { + + private Long id; + private Long holdingId; + private String tradeType; + private LocalDate tradeDate; + private Long quantity; + private Long price; + private Long fee; + private Long amount; // 거래금액 = 수량 × 단가 (수수료 제외) + + public static TradeResponse from(InvestTrade t) { + return TradeResponse.builder() + .id(t.getId()) + .holdingId(t.getHoldingId()) + .tradeType(t.getTradeType()) + .tradeDate(t.getTradeDate()) + .quantity(t.getQuantity()) + .price(t.getPrice()) + .fee(t.getFee()) + .amount(t.getQuantity() * t.getPrice()) + .build(); + } +} diff --git a/src/main/java/com/sb/web/account/dto/WalletRequest.java b/src/main/java/com/sb/web/account/dto/WalletRequest.java new file mode 100644 index 0000000..90e7585 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/WalletRequest.java @@ -0,0 +1,40 @@ +package com.sb.web.account.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 계좌/카드/대출 생성/수정 요청. + */ +@Data +public class WalletRequest { + + @NotBlank(message = "유형을 선택하세요.") + @Pattern(regexp = "BANK|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.") + private String type; + + @NotBlank(message = "이름을 입력하세요.") + @Size(max = 50, message = "이름은 50자 이내여야 합니다.") + private String name; + + @Size(max = 50, message = "50자 이내여야 합니다.") + private String issuer; // 은행명 / 카드사 + + @Size(max = 50, message = "50자 이내여야 합니다.") + private String accountNumber; // 계좌번호 (BANK) + + @Pattern(regexp = "CREDIT|CHECK|", message = "카드 종류가 올바르지 않습니다.") + private String cardType; // CREDIT / CHECK (CARD) + + /** 초기 잔액 (부호있음: 자산+, 부채-). 미입력 시 0 */ + private Long openingBalance; + + private LocalDate openingDate; + + /** 현재 평가금액 (INVEST 전용, 수동 갱신) */ + private Long currentValue; +} diff --git a/src/main/java/com/sb/web/account/dto/WalletResponse.java b/src/main/java/com/sb/web/account/dto/WalletResponse.java new file mode 100644 index 0000000..cc03dca --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/WalletResponse.java @@ -0,0 +1,45 @@ +package com.sb.web.account.dto; + +import com.sb.web.account.domain.Wallet; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 계좌/카드/대출 응답. balance 는 현재 잔액(서비스에서 계산해 채움). + */ +@Data +@Builder +public class WalletResponse { + + private Long id; + private String type; + private String name; + private String issuer; + private String accountNumber; + private String cardType; + private Long openingBalance; + private LocalDate openingDate; + private Long balance; // 현재 잔액(부호있음). INVEST는 총평가(예수금+주식) + + // 투자(INVEST) 전용 — 서비스에서 포트폴리오로 산출 + private Long investedAmount; // 순입금(투입원금) = 입금−출금 + private Long deposit; // 예수금(미투자 현금) = 투입원금 + 매도 − 매수 + private Long stockValue; // 주식 평가금액 + private Long valuationGain; // 총손익(실현+평가) = 총평가 − 투입원금 + + public static WalletResponse from(Wallet w, long balance) { + return WalletResponse.builder() + .id(w.getId()) + .type(w.getType()) + .name(w.getName()) + .issuer(w.getIssuer()) + .accountNumber(w.getAccountNumber()) + .cardType(w.getCardType()) + .openingBalance(w.getOpeningBalance()) + .openingDate(w.getOpeningDate()) + .balance(balance) + .build(); + } +} diff --git a/src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java b/src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java new file mode 100644 index 0000000..f1aa1c1 --- /dev/null +++ b/src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java @@ -0,0 +1,77 @@ +package com.sb.web.account.mapper; + +import com.sb.web.account.domain.AccountEntry; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 가계부 매퍼. 모든 조회/수정은 member_id(소유자)로 격리한다. + */ +@Mapper +public interface AccountEntryMapper { + + List findByMember(@Param("memberId") Long memberId, + @Param("year") Integer year, + @Param("month") Integer month, + @Param("type") String type, + @Param("category") String category, + @Param("walletId") Long walletId, + @Param("keyword") String keyword, + @Param("tagId") Long tagId); + + /** 특정 계좌(wallet)와 연관된 내역 (wallet_id 또는 to_wallet_id 가 해당 계좌) */ + List findByWalletAndMember(@Param("memberId") Long memberId, + @Param("walletId") Long walletId); + + AccountEntry findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); + + /** 구분(type)별 합계 — {type, total} 행 반환 */ + List> sumByType(@Param("memberId") Long memberId, + @Param("year") Integer year, + @Param("month") Integer month); + + /** 카테고리별 지출 합계 — {category, total} 행 반환 (예산 대비용) */ + List> sumExpenseByCategory(@Param("memberId") Long memberId, + @Param("year") Integer year, + @Param("month") Integer month); + + /** 구분(type)별 분류 합계 — {category, total} 행 반환 (파이차트용) */ + List> sumByCategory(@Param("memberId") Long memberId, + @Param("type") String type, + @Param("year") Integer year, + @Param("month") Integer month); + + /** 월별 순현금흐름(수입−지출, 계좌 지정분) — {ym, net} 행 반환 (순자산 추이용) */ + List> monthlyWalletFlow(@Param("memberId") Long memberId); + + /** 기간 버킷(일/주/월/년)별 수입·지출 — {bucket, income, expense} (이체 제외) */ + List> statsByPeriod(@Param("memberId") Long memberId, + @Param("unit") String unit, + @Param("year") Integer year, + @Param("month") Integer month); + + int insert(AccountEntry entry); + + int update(AccountEntry entry); + + int delete(@Param("id") Long id, @Param("memberId") Long memberId); + + /** 기존 내역의 (type별) 사용된 분류명 distinct (분류 불러오기용) */ + List findDistinctCategories(@Param("memberId") Long memberId, @Param("type") String type); + + /** 분류 이름 변경 시 기존 내역 일괄 갱신 */ + int renameCategory(@Param("memberId") Long memberId, + @Param("type") String type, + @Param("oldName") String oldName, + @Param("newName") String newName); + + /* ----- 항목-태그 ----- */ + int insertEntryTag(@Param("entryId") Long entryId, @Param("tagId") Long tagId); + + int deleteEntryTagsByEntryId(@Param("entryId") Long entryId); + + List findTagNamesByEntryId(@Param("entryId") Long entryId); +} diff --git a/src/main/java/com/sb/web/account/mapper/AccountTagMapper.java b/src/main/java/com/sb/web/account/mapper/AccountTagMapper.java new file mode 100644 index 0000000..66b5c32 --- /dev/null +++ b/src/main/java/com/sb/web/account/mapper/AccountTagMapper.java @@ -0,0 +1,34 @@ +package com.sb.web.account.mapper; + +import com.sb.web.account.domain.AccountTag; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 가계부 태그 매퍼. 모든 동작은 member_id(소유자)로 격리. + */ +@Mapper +public interface AccountTagMapper { + + List findByMember(@Param("memberId") Long memberId); + + AccountTag findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); + + int countByName(@Param("memberId") Long memberId, + @Param("name") String name, + @Param("excludeId") Long excludeId); + + int insert(AccountTag tag); + + int update(AccountTag tag); + + int delete(@Param("id") Long id, @Param("memberId") Long memberId); + + /** 주어진 ID 중 해당 사용자 소유로 실제 존재하는 태그 ID만 반환 */ + List findExistingIds(@Param("memberId") Long memberId, @Param("ids") List ids); + + /** 태그 삭제 시 항목 연결도 제거 */ + int deleteEntryLinksByTagId(@Param("tagId") Long tagId); +} diff --git a/src/main/java/com/sb/web/account/mapper/BudgetMapper.java b/src/main/java/com/sb/web/account/mapper/BudgetMapper.java new file mode 100644 index 0000000..0c147ec --- /dev/null +++ b/src/main/java/com/sb/web/account/mapper/BudgetMapper.java @@ -0,0 +1,33 @@ +package com.sb.web.account.mapper; + +import com.sb.web.account.domain.Budget; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 예산 매퍼. 모든 동작은 member_id(소유자)로 격리. + */ +@Mapper +public interface BudgetMapper { + + List findByMember(@Param("memberId") Long memberId); + + Budget findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); + + int countByCategory(@Param("memberId") Long memberId, + @Param("category") String category, + @Param("excludeId") Long excludeId); + + int insert(Budget budget); + + int update(Budget budget); + + int delete(@Param("id") Long id, @Param("memberId") Long memberId); + + /** 분류 이름 변경 시 예산 일괄 갱신 */ + int renameCategory(@Param("memberId") Long memberId, + @Param("oldName") String oldName, + @Param("newName") String newName); +} diff --git a/src/main/java/com/sb/web/account/mapper/CategoryMapper.java b/src/main/java/com/sb/web/account/mapper/CategoryMapper.java new file mode 100644 index 0000000..682f5df --- /dev/null +++ b/src/main/java/com/sb/web/account/mapper/CategoryMapper.java @@ -0,0 +1,31 @@ +package com.sb.web.account.mapper; + +import com.sb.web.account.domain.AccountCategory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 분류 매퍼. 모든 동작은 member_id(소유자)로 격리. + */ +@Mapper +public interface CategoryMapper { + + List findByMember(@Param("memberId") Long memberId); + + AccountCategory findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); + + int countByName(@Param("memberId") Long memberId, + @Param("type") String type, + @Param("name") String name, + @Param("excludeId") Long excludeId); + + int insert(AccountCategory category); + + int insertIgnore(AccountCategory category); // 기존 분류 불러오기용 + + int update(AccountCategory category); + + int delete(@Param("id") Long id, @Param("memberId") Long memberId); +} diff --git a/src/main/java/com/sb/web/account/mapper/InvestMapper.java b/src/main/java/com/sb/web/account/mapper/InvestMapper.java new file mode 100644 index 0000000..11b24a5 --- /dev/null +++ b/src/main/java/com/sb/web/account/mapper/InvestMapper.java @@ -0,0 +1,44 @@ +package com.sb.web.account.mapper; + +import com.sb.web.account.domain.InvestHolding; +import com.sb.web.account.domain.InvestTrade; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 투자 보유종목/매매이력 매퍼. 모든 조회/수정은 member_id(소유자)로 격리한다. + */ +@Mapper +public interface InvestMapper { + + /* ===== 보유 종목 ===== */ + List findHoldingsByMember(@Param("memberId") Long memberId); + + List findHoldingsByWallet(@Param("memberId") Long memberId, + @Param("walletId") Long walletId); + + InvestHolding findHoldingByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); + + int insertHolding(InvestHolding holding); + + int updateHolding(InvestHolding holding); + + int deleteHolding(@Param("id") Long id, @Param("memberId") Long memberId); + + /* ===== 매매 이력 ===== */ + /** 회원의 전체 매매(집계용) — 종목·거래일·id 순 */ + List findTradesByMember(@Param("memberId") Long memberId); + + List findTradesByHolding(@Param("holdingId") Long holdingId, + @Param("memberId") Long memberId); + + InvestTrade findTradeByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); + + int insertTrade(InvestTrade trade); + + int deleteTrade(@Param("id") Long id, @Param("memberId") Long memberId); + + int deleteTradesByHolding(@Param("holdingId") Long holdingId, @Param("memberId") Long memberId); +} diff --git a/src/main/java/com/sb/web/account/mapper/RecurringMapper.java b/src/main/java/com/sb/web/account/mapper/RecurringMapper.java new file mode 100644 index 0000000..163e619 --- /dev/null +++ b/src/main/java/com/sb/web/account/mapper/RecurringMapper.java @@ -0,0 +1,30 @@ +package com.sb.web.account.mapper; + +import com.sb.web.account.domain.Recurring; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDate; +import java.util.List; + +/** + * 정기 거래 매퍼. 모든 동작은 member_id(소유자)로 격리. + */ +@Mapper +public interface RecurringMapper { + + List findByMember(@Param("memberId") Long memberId); + + /** 활성 규칙 (자동 생성용) */ + List findActiveByMember(@Param("memberId") Long memberId); + + Recurring findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); + + int insert(Recurring r); + + int update(Recurring r); + + int delete(@Param("id") Long id, @Param("memberId") Long memberId); + + int updateLastRun(@Param("id") Long id, @Param("lastRunDate") LocalDate lastRunDate); +} diff --git a/src/main/java/com/sb/web/account/mapper/WalletMapper.java b/src/main/java/com/sb/web/account/mapper/WalletMapper.java new file mode 100644 index 0000000..bf6025d --- /dev/null +++ b/src/main/java/com/sb/web/account/mapper/WalletMapper.java @@ -0,0 +1,28 @@ +package com.sb.web.account.mapper; + +import com.sb.web.account.domain.Wallet; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 계좌/카드/대출 매퍼. 모든 동작은 member_id(소유자)로 격리. + */ +@Mapper +public interface WalletMapper { + + List findByMember(@Param("memberId") Long memberId); + + /** 계좌별 현재 잔액 {id, balance} */ + List> findBalances(@Param("memberId") Long memberId); + + Wallet findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); + + int insert(Wallet wallet); + + int update(Wallet wallet); + + int delete(@Param("id") Long id, @Param("memberId") Long memberId); +} diff --git a/src/main/java/com/sb/web/account/service/AccountService.java b/src/main/java/com/sb/web/account/service/AccountService.java new file mode 100644 index 0000000..e7c3d27 --- /dev/null +++ b/src/main/java/com/sb/web/account/service/AccountService.java @@ -0,0 +1,465 @@ +package com.sb.web.account.service; + +import com.sb.web.account.domain.AccountEntry; +import com.sb.web.account.domain.AccountTag; +import com.sb.web.account.domain.Wallet; +import com.sb.web.account.dto.AccountEntryRequest; +import com.sb.web.account.dto.AccountEntryResponse; +import com.sb.web.account.dto.AccountSummary; +import com.sb.web.account.dto.AccountTagRequest; +import com.sb.web.account.dto.AccountTagResponse; +import com.sb.web.account.dto.CategoryStat; +import com.sb.web.account.dto.NetWorthPoint; +import com.sb.web.account.dto.NetWorthResponse; +import com.sb.web.account.dto.PeriodStat; +import com.sb.web.account.dto.RepaymentRequest; +import com.sb.web.account.dto.WalletRequest; +import com.sb.web.account.dto.WalletResponse; +import com.sb.web.account.mapper.AccountEntryMapper; +import com.sb.web.account.mapper.AccountTagMapper; +import com.sb.web.account.mapper.WalletMapper; +import com.sb.web.common.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 가계부 서비스. 항목·태그 모두 memberId(소유자)로 격리되어 본인 데이터만 접근한다. + * 가계부 태그는 사용자별(account_tag)로 관리한다. + */ +@Service +@RequiredArgsConstructor +public class AccountService { + + private final AccountEntryMapper mapper; + private final AccountTagMapper tagMapper; + private final WalletMapper walletMapper; + private final InvestService investService; + + /* ===================== 항목 ===================== */ + + public List 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) + .toList(); + } + + /** 분류(카테고리)별 합계 — 파이차트용. type=EXPENSE(기본)/INCOME */ + public List categoryStats(Long memberId, String type, Integer year, Integer month) { + String t = "INCOME".equalsIgnoreCase(type) ? "INCOME" : "EXPENSE"; + return mapper.sumByCategory(memberId, t, year, month).stream() + .map(r -> CategoryStat.builder() + .category((String) r.get("category")) + .total(((Number) r.get("total")).longValue()) + .build()) + .toList(); + } + + /** 월별 순자산 추이 (최근 months개월, 기본 12). 순자산 = 개시잔액 + 누적(수입−지출, 계좌 지정분) */ + public List netWorthTrend(Long memberId, Integer months) { + int n = (months == null || months <= 0 || months > 60) ? 12 : months; + YearMonth current = YearMonth.from(java.time.LocalDate.now()); + YearMonth start = current.minusMonths(n - 1L); + + Map flow = new HashMap<>(); + for (Map row : mapper.monthlyWalletFlow(memberId)) { + Object net = row.get("net"); + flow.put((String) row.get("ym"), net == null ? 0L : ((Number) net).longValue()); + } + + // 개시잔액: 개시월이 범위 시작 이전(또는 미상)이면 기준선, 범위 내면 해당 월 버킷, 미래면 무시 + List ws = walletMapper.findByMember(memberId); + Map openingByMonth = new HashMap<>(); + long baseline = 0; + for (Wallet w : ws) { + long ob = w.getOpeningBalance() == null ? 0L : w.getOpeningBalance(); + if (ob == 0) { + continue; + } + YearMonth om = w.getOpeningDate() == null ? null : YearMonth.from(w.getOpeningDate()); + if (om == null || om.isBefore(start)) { + baseline += ob; + } else if (!om.isAfter(current)) { + openingByMonth.merge(om.toString(), ob, Long::sum); + } + } + // 범위 시작 이전의 현금흐름은 기준선에 누적 + String startKey = start.toString(); + for (Map.Entry e : flow.entrySet()) { + if (e.getKey().compareTo(startKey) < 0) { + baseline += e.getValue(); + } + } + + List result = new ArrayList<>(); + long running = baseline; + for (YearMonth m = start; !m.isAfter(current); m = m.plusMonths(1)) { + String key = m.toString(); + running += flow.getOrDefault(key, 0L); + running += openingByMonth.getOrDefault(key, 0L); + result.add(NetWorthPoint.builder().month(key).netWorth(running).build()); + } + + // 투자 손익(실현+평가)은 현재 시점에만 반영 (과거 평가 이력이 없으므로 마지막 점에 가산) + // 매매에 따른 예수금 증감 + 주식 평가금액 = 현금기준 대비 순자산 증감분 + if (!result.isEmpty()) { + long investAdjust = 0; + for (InvestService.WalletInvest wi : investService.valuationByWallet(memberId).values()) { + investAdjust += wi.cashDelta + wi.stockEval; + } + if (investAdjust != 0) { + NetWorthPoint last = result.get(result.size() - 1); + last.setNetWorth(last.getNetWorth() + investAdjust); + } + } + return result; + } + + /** 기간 버킷(일/주/월/년)별 수입·지출 통계 */ + public List stats(Long memberId, String unit, Integer year, Integer month) { + String u = switch (unit == null ? "" : unit.toUpperCase()) { + case "DAY", "WEEK", "YEAR" -> unit.toUpperCase(); + default -> "MONTH"; + }; + return mapper.statsByPeriod(memberId, u, year, month).stream() + .map(row -> PeriodStat.builder() + .bucket(String.valueOf(((Number) row.get("bucket")).longValue())) + .income(((Number) row.get("income")).longValue()) + .expense(((Number) row.get("expense")).longValue()) + .build()) + .toList(); + } + + public AccountSummary summary(Long memberId, Integer year, Integer month) { + long income = 0; + long expense = 0; + for (Map row : mapper.sumByType(memberId, year, month)) { + String type = (String) row.get("type"); + long total = ((Number) row.get("total")).longValue(); + if ("INCOME".equals(type)) { + income = total; + } else if ("EXPENSE".equals(type)) { + expense = total; + } + } + return AccountSummary.of(income, expense); + } + + @Transactional + public AccountEntryResponse create(AccountEntryRequest req, Long memberId) { + validateEntryWallets(req, memberId); + boolean transfer = "TRANSFER".equals(req.getType()); + AccountEntry entry = AccountEntry.builder() + .memberId(memberId) + .entryDate(req.getEntryDate()) + .type(req.getType()) + .category(transfer ? null : req.getCategory()) + .amount(req.getAmount()) + .memo(req.getMemo()) + .walletId(req.getWalletId()) + .toWalletId(transfer ? req.getToWalletId() : null) + .build(); + mapper.insert(entry); + applyTags(entry.getId(), req.getTagIds(), memberId); + return toResponse(mapper.findByIdAndMember(entry.getId(), memberId)); + } + + @Transactional + public AccountEntryResponse update(Long id, AccountEntryRequest req, Long memberId) { + AccountEntry entry = mustFind(id, memberId); + validateEntryWallets(req, memberId); + boolean transfer = "TRANSFER".equals(req.getType()); + entry.setEntryDate(req.getEntryDate()); + entry.setType(req.getType()); + entry.setCategory(transfer ? null : req.getCategory()); + entry.setAmount(req.getAmount()); + entry.setMemo(req.getMemo()); + entry.setWalletId(req.getWalletId()); + entry.setToWalletId(transfer ? req.getToWalletId() : null); + mapper.update(entry); + + mapper.deleteEntryTagsByEntryId(id); + applyTags(id, req.getTagIds(), memberId); + return toResponse(mapper.findByIdAndMember(id, memberId)); + } + + @Transactional + public void delete(Long id, Long memberId) { + mustFind(id, memberId); + mapper.deleteEntryTagsByEntryId(id); + mapper.delete(id, memberId); + } + + /* ===================== 상환/납부 ===================== */ + + /** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자는 지출로 분리해 2건 생성 */ + @Transactional + public void repay(RepaymentRequest req, Long memberId) { + long principal = req.getPrincipal() != null ? req.getPrincipal() : 0L; + long interest = req.getInterest() != null ? req.getInterest() : 0L; + if (principal <= 0 && interest <= 0) { + throw new ApiException(HttpStatus.BAD_REQUEST, "원금 또는 이자를 입력하세요."); + } + requireOwnedWallet(req.getFromWalletId(), memberId); + Wallet target = walletMapper.findByIdAndMember(req.getTargetWalletId(), memberId); + if (target == null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "대상 계좌가 올바르지 않습니다."); + } + if (!"LOAN".equals(target.getType()) && !"CARD".equals(target.getType())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "상환 대상은 대출/카드만 가능합니다."); + } + if (req.getFromWalletId().equals(req.getTargetWalletId())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "출금/대상 계좌가 같을 수 없습니다."); + } + + // 원금: 이체 (출금 → 대상, 부채 감소) + if (principal > 0) { + mapper.insert(AccountEntry.builder() + .memberId(memberId) + .entryDate(req.getEntryDate()) + .type("TRANSFER") + .amount(principal) + .walletId(req.getFromWalletId()) + .toWalletId(req.getTargetWalletId()) + .memo(req.getMemo() != null ? req.getMemo() : "원금상환") + .build()); + } + // 이자: 지출 (출금 계좌에서, 분류=이자) + if (interest > 0) { + mapper.insert(AccountEntry.builder() + .memberId(memberId) + .entryDate(req.getEntryDate()) + .type("EXPENSE") + .category("이자") + .amount(interest) + .walletId(req.getFromWalletId()) + .memo(req.getMemo() != null ? req.getMemo() : "이자") + .build()); + } + } + + /* ===================== 계좌/카드 (사용자별) ===================== */ + + public List listWallets(Long memberId) { + Map balances = balanceMap(memberId); + Map inv = investService.valuationByWallet(memberId); + return walletMapper.findByMember(memberId).stream() + .map(w -> { + long cash = balances.getOrDefault(w.getId(), 0L); + WalletResponse r = WalletResponse.from(w, cash); + if ("INVEST".equals(w.getType())) { + InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest()); + long deposit = cash + wi.cashDelta; // 예수금 + long total = deposit + wi.stockEval; // 총평가 + r.setBalance(total); + r.setInvestedAmount(cash); // 순입금(투입원금) + r.setDeposit(deposit); + r.setStockValue(wi.stockEval); + r.setValuationGain(total - cash); // 실현+평가 손익 + } + return r; + }) + .toList(); + } + + /** 순자산 = 총자산(BANK) − 총부채(CARD/LOAN) */ + public NetWorthResponse netWorth(Long memberId) { + Map balances = balanceMap(memberId); + Map inv = investService.valuationByWallet(memberId); + long assets = 0; + long liabilities = 0; + for (Wallet w : walletMapper.findByMember(memberId)) { + long bal = balances.getOrDefault(w.getId(), 0L); + if ("INVEST".equals(w.getType())) { + // 투자: 예수금(현금+매매증감) + 주식 평가금액 + InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest()); + assets += bal + wi.cashDelta + wi.stockEval; + } else if ("BANK".equals(w.getType())) { + assets += bal; + } else { + liabilities += -bal; // 부채는 음수 잔액 → 양수 부채로 표시 + } + } + return NetWorthResponse.builder() + .totalAssets(assets) + .totalLiabilities(liabilities) + .netWorth(assets - liabilities) + .build(); + } + + @Transactional + public WalletResponse createWallet(WalletRequest req, Long memberId) { + Wallet wallet = toWallet(req); + wallet.setMemberId(memberId); + walletMapper.insert(wallet); + return WalletResponse.from(wallet, wallet.getOpeningBalance()); // 신규 → 잔액=초기잔액 + } + + @Transactional + public WalletResponse updateWallet(Long id, WalletRequest req, Long memberId) { + Wallet wallet = mustFindWallet(id, memberId); + wallet.setType(req.getType()); + wallet.setName(req.getName().trim()); + wallet.setIssuer(blankToNull(req.getIssuer())); + wallet.setAccountNumber("BANK".equals(req.getType()) ? 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); + walletMapper.update(wallet); + return WalletResponse.from(wallet, balanceMap(memberId).getOrDefault(id, wallet.getOpeningBalance())); + } + + private Map balanceMap(Long memberId) { + Map map = new HashMap<>(); + for (Map row : walletMapper.findBalances(memberId)) { + Long id = ((Number) row.get("id")).longValue(); + Object b = row.get("balance"); + map.put(id, b == null ? 0L : ((Number) b).longValue()); + } + return map; + } + + @Transactional + public void deleteWallet(Long id, Long memberId) { + mustFindWallet(id, memberId); + walletMapper.delete(id, memberId); + } + + /** 특정 계좌와 연관된 내역 목록 */ + public List listEntriesByWallet(Long walletId, Long memberId) { + mustFindWallet(walletId, memberId); + return mapper.findByWalletAndMember(memberId, walletId).stream() + .map(this::toResponse) + .toList(); + } + + private Wallet toWallet(WalletRequest req) { + return Wallet.builder() + .type(req.getType()) + .name(req.getName().trim()) + .issuer(blankToNull(req.getIssuer())) + .accountNumber("BANK".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) + .build(); + } + + private Wallet mustFindWallet(Long id, Long memberId) { + Wallet wallet = walletMapper.findByIdAndMember(id, memberId); + if (wallet == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "계좌를 찾을 수 없습니다."); + } + return wallet; + } + + /** 내역의 계좌 검증. 이체는 출금/입금 계좌 필수·본인·서로 다름, 그 외는 선택 계좌만 검증 */ + private void validateEntryWallets(AccountEntryRequest req, Long memberId) { + if ("TRANSFER".equals(req.getType())) { + if (req.getWalletId() == null || req.getToWalletId() == null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "이체는 출금/입금 계좌를 모두 선택해야 합니다."); + } + if (req.getWalletId().equals(req.getToWalletId())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "출금/입금 계좌가 같을 수 없습니다."); + } + requireOwnedWallet(req.getWalletId(), memberId); + requireOwnedWallet(req.getToWalletId(), memberId); + } else if (req.getWalletId() != null) { + requireOwnedWallet(req.getWalletId(), memberId); + } + } + + private void requireOwnedWallet(Long walletId, Long memberId) { + if (walletMapper.findByIdAndMember(walletId, memberId) == null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "선택한 계좌가 올바르지 않습니다."); + } + } + + private String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } + + /* ===================== 태그 (사용자별) ===================== */ + + public List listTags(Long memberId) { + return tagMapper.findByMember(memberId).stream().map(AccountTagResponse::from).toList(); + } + + @Transactional + public AccountTagResponse createTag(AccountTagRequest req, Long memberId) { + String name = req.getName().trim(); + if (tagMapper.countByName(memberId, name, null) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다."); + } + AccountTag tag = AccountTag.builder().memberId(memberId).name(name).build(); + tagMapper.insert(tag); + return AccountTagResponse.from(tag); + } + + @Transactional + public AccountTagResponse updateTag(Long id, AccountTagRequest req, Long memberId) { + AccountTag tag = mustFindTag(id, memberId); + String name = req.getName().trim(); + if (tagMapper.countByName(memberId, name, id) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다."); + } + tag.setName(name); + tagMapper.update(tag); + return AccountTagResponse.from(tag); + } + + @Transactional + public void deleteTag(Long id, Long memberId) { + mustFindTag(id, memberId); + tagMapper.deleteEntryLinksByTagId(id); + tagMapper.delete(id, memberId); + } + + /* ===================== 내부 ===================== */ + + private AccountEntryResponse toResponse(AccountEntry entry) { + return AccountEntryResponse.from(entry, mapper.findTagNamesByEntryId(entry.getId())); + } + + /** 선택된 태그 ID 중 해당 사용자 소유로 존재하는 것만 연결 */ + private void applyTags(Long entryId, List tagIds, Long memberId) { + if (tagIds == null || tagIds.isEmpty()) { + return; + } + List distinct = tagIds.stream().filter(Objects::nonNull).distinct().toList(); + if (distinct.isEmpty()) { + return; + } + for (Long tagId : tagMapper.findExistingIds(memberId, distinct)) { + mapper.insertEntryTag(entryId, tagId); + } + } + + private AccountEntry mustFind(Long id, Long memberId) { + AccountEntry entry = mapper.findByIdAndMember(id, memberId); + if (entry == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "내역을 찾을 수 없습니다."); + } + return entry; + } + + private AccountTag mustFindTag(Long id, Long memberId) { + AccountTag tag = tagMapper.findByIdAndMember(id, memberId); + if (tag == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "태그를 찾을 수 없습니다."); + } + return tag; + } +} diff --git a/src/main/java/com/sb/web/account/service/BudgetService.java b/src/main/java/com/sb/web/account/service/BudgetService.java new file mode 100644 index 0000000..9707f70 --- /dev/null +++ b/src/main/java/com/sb/web/account/service/BudgetService.java @@ -0,0 +1,230 @@ +package com.sb.web.account.service; + +import com.sb.web.account.domain.Budget; +import com.sb.web.account.dto.BudgetPeriodStat; +import com.sb.web.account.dto.BudgetRequest; +import com.sb.web.account.dto.BudgetResponse; +import com.sb.web.account.dto.BudgetStatus; +import com.sb.web.account.mapper.AccountEntryMapper; +import com.sb.web.account.mapper.BudgetMapper; +import com.sb.web.common.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Year; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +/** + * 예산 서비스. 사용자별 카테고리 예산 CRUD + 월 예산(실제 일수 반영) 대비 지출 현황. + */ +@Service +@RequiredArgsConstructor +public class BudgetService { + + private final BudgetMapper budgetMapper; + private final AccountEntryMapper entryMapper; + + public List list(Long memberId) { + return budgetMapper.findByMember(memberId).stream().map(BudgetResponse::from).toList(); + } + + @Transactional + public BudgetResponse create(BudgetRequest req, Long memberId) { + String category = req.getCategory().trim(); + if (budgetMapper.countByCategory(memberId, category, null) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다."); + } + Budget budget = toBudget(req, category); + budget.setMemberId(memberId); + budgetMapper.insert(budget); + return BudgetResponse.from(budget); + } + + @Transactional + public BudgetResponse update(Long id, BudgetRequest req, Long memberId) { + mustFind(id, memberId); + String category = req.getCategory().trim(); + if (budgetMapper.countByCategory(memberId, category, id) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다."); + } + Budget budget = toBudget(req, category); + budget.setId(id); + budget.setMemberId(memberId); + budgetMapper.update(budget); + return BudgetResponse.from(budget); + } + + @Transactional + public void delete(Long id, Long memberId) { + mustFind(id, memberId); + budgetMapper.delete(id, memberId); + } + + /** 해당 월의 예산 대비 지출 현황 (고정 예산은 그 달 실제 일수 반영) */ + public List status(Long memberId, int year, int month) { + int daysInMonth = YearMonth.of(year, month).lengthOfMonth(); + + Map spentByCategory = new HashMap<>(); + for (Map row : entryMapper.sumExpenseByCategory(memberId, year, month)) { + Object cat = row.get("category"); + long total = ((Number) row.get("total")).longValue(); + spentByCategory.put(cat == null ? "" : cat.toString(), total); + } + + List result = new ArrayList<>(); + for (Budget b : budgetMapper.findByMember(memberId)) { + long monthlyBudget = monthlyBudget(b, daysInMonth); + long spent = spentByCategory.getOrDefault(b.getCategory(), 0L); + result.add(BudgetStatus.builder() + .id(b.getId()) + .category(b.getCategory()) + .fixed(Boolean.TRUE.equals(b.getFixed())) + .monthlyBudget(monthlyBudget) + .spent(spent) + .remaining(monthlyBudget - spent) + .build()); + } + return result; + } + + /** 기간 버킷(일/주/월/년)별 예산 대비 지출 (대시보드 막대그래프용) */ + public List periodStats(Long memberId, String unitRaw, Integer year, Integer month) { + String unit = switch (unitRaw == null ? "" : unitRaw.toUpperCase()) { + case "DAY", "WEEK", "YEAR" -> unitRaw.toUpperCase(); + default -> "MONTH"; + }; + int y = year != null ? year : Year.now().getValue(); + int m = month != null ? month : 1; + + List budgets = budgetMapper.findByMember(memberId); + Map expense = new HashMap<>(); + for (Map row : entryMapper.statsByPeriod(memberId, unit, year, month)) { + expense.put(String.valueOf(((Number) row.get("bucket")).longValue()), + ((Number) row.get("expense")).longValue()); + } + + List result = new ArrayList<>(); + switch (unit) { + case "DAY" -> { + int days = YearMonth.of(y, m).lengthOfMonth(); + long dayBudget = sum(budgets, b -> dayBudget(b, y, m)); + for (int d = 1; d <= days; d++) result.add(stat(String.valueOf(d), dayBudget, expense)); + } + case "WEEK" -> { + int days = YearMonth.of(y, m).lengthOfMonth(); + int weeks = (int) Math.ceil(days / 7.0); + long weekBudget = sum(budgets, b -> weekBudget(b, y, m)); + for (int w = 1; w <= weeks; w++) result.add(stat(String.valueOf(w), weekBudget, expense)); + } + case "YEAR" -> { + TreeSet years = new TreeSet<>(); + for (String k : expense.keySet()) years.add(Integer.parseInt(k)); + if (years.isEmpty()) years.add(y); + for (int yr : years) { + long yb = sum(budgets, b -> yearBudget(b, yr)); + result.add(stat(String.valueOf(yr), yb, expense)); + } + } + default -> { + for (int mm = 1; mm <= 12; mm++) { + final int month0 = mm; + long mb = sum(budgets, b -> monthlyBudget(b, YearMonth.of(y, month0).lengthOfMonth())); + result.add(stat(String.valueOf(mm), mb, expense)); + } + } + } + return result; + } + + private interface BudgetFn { + long apply(Budget b); + } + + private long sum(List budgets, BudgetFn fn) { + long total = 0; + for (Budget b : budgets) total += fn.apply(b); + return total; + } + + private BudgetPeriodStat stat(String bucket, long budget, Map expense) { + return BudgetPeriodStat.builder().bucket(bucket).budget(budget).expense(expense.getOrDefault(bucket, 0L)).build(); + } + + private double dailyRate(Budget b) { + long base = b.getBaseAmount() != null ? b.getBaseAmount() : 0L; + return (double) base / baseDays(b.getBaseUnit()); + } + + private long dayBudget(Budget b, int year, int month) { + if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b)); + if (b.getDaily() != null) return b.getDaily(); + if (b.getMonthly() != null) return Math.round((double) b.getMonthly() / YearMonth.of(year, month).lengthOfMonth()); + if (b.getWeekly() != null) return Math.round(b.getWeekly() / 7.0); + return 0; + } + + private long weekBudget(Budget b, int year, int month) { + if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b) * 7); + if (b.getWeekly() != null) return b.getWeekly(); + if (b.getDaily() != null) return b.getDaily() * 7; + if (b.getMonthly() != null) return Math.round((double) b.getMonthly() / YearMonth.of(year, month).lengthOfMonth() * 7); + return 0; + } + + private long yearBudget(Budget b, int year) { + if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b) * Year.of(year).length()); + if (b.getYearly() != null) return b.getYearly(); + if (b.getMonthly() != null) return b.getMonthly() * 12; + return 0; + } + + /** 이 달 예산: 고정이면 일예산×해당월 일수, 비고정이면 monthly 값 */ + private long monthlyBudget(Budget b, int daysInMonth) { + if (Boolean.TRUE.equals(b.getFixed())) { + long baseAmount = b.getBaseAmount() != null ? b.getBaseAmount() : 0L; + double dailyRate = (double) baseAmount / baseDays(b.getBaseUnit()); + return Math.round(dailyRate * daysInMonth); + } + return b.getMonthly() != null ? b.getMonthly() : 0L; + } + + private int baseDays(String unit) { + if ("WEEK".equals(unit)) return 7; + if ("MONTH".equals(unit)) return 30; + return 1; // DAY + } + + private Budget toBudget(BudgetRequest req, String category) { + if (req.isFixed()) { + return Budget.builder() + .category(category) + .fixed(true) + .baseUnit(req.getBaseUnit()) + .baseAmount(req.getBaseAmount()) + .build(); + } + return Budget.builder() + .category(category) + .fixed(false) + .daily(req.getDaily()) + .weekly(req.getWeekly()) + .monthly(req.getMonthly()) + .yearly(req.getYearly()) + .build(); + } + + private Budget mustFind(Long id, Long memberId) { + Budget b = budgetMapper.findByIdAndMember(id, memberId); + if (b == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "예산을 찾을 수 없습니다."); + } + return b; + } +} diff --git a/src/main/java/com/sb/web/account/service/CategoryService.java b/src/main/java/com/sb/web/account/service/CategoryService.java new file mode 100644 index 0000000..4b25c3a --- /dev/null +++ b/src/main/java/com/sb/web/account/service/CategoryService.java @@ -0,0 +1,89 @@ +package com.sb.web.account.service; + +import com.sb.web.account.domain.AccountCategory; +import com.sb.web.account.dto.CategoryRequest; +import com.sb.web.account.dto.CategoryResponse; +import com.sb.web.account.mapper.AccountEntryMapper; +import com.sb.web.account.mapper.BudgetMapper; +import com.sb.web.account.mapper.CategoryMapper; +import com.sb.web.common.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기존 분류 불러오기. + */ +@Service +@RequiredArgsConstructor +public class CategoryService { + + private final CategoryMapper categoryMapper; + private final AccountEntryMapper entryMapper; + private final BudgetMapper budgetMapper; + + public List list(Long memberId) { + return categoryMapper.findByMember(memberId).stream().map(CategoryResponse::from).toList(); + } + + @Transactional + public CategoryResponse create(CategoryRequest req, Long memberId) { + String name = req.getName().trim(); + if (categoryMapper.countByName(memberId, req.getType(), name, null) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다."); + } + AccountCategory c = AccountCategory.builder() + .memberId(memberId).type(req.getType()).name(name).sortOrder(0).build(); + categoryMapper.insert(c); + return CategoryResponse.from(c); + } + + /** 이름만 변경 가능. 변경 시 기존 내역·예산의 분류명도 함께 갱신 */ + @Transactional + public CategoryResponse update(Long id, CategoryRequest req, Long memberId) { + AccountCategory c = mustFind(id, memberId); + String newName = req.getName().trim(); + if (categoryMapper.countByName(memberId, c.getType(), newName, id) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다."); + } + String oldName = c.getName(); + c.setName(newName); + categoryMapper.update(c); + if (!oldName.equals(newName)) { + entryMapper.renameCategory(memberId, c.getType(), oldName, newName); + if ("EXPENSE".equals(c.getType())) { + budgetMapper.renameCategory(memberId, oldName, newName); + } + } + return CategoryResponse.from(c); + } + + @Transactional + public void delete(Long id, Long memberId) { + mustFind(id, memberId); + categoryMapper.delete(id, memberId); + } + + /** 기존 내역에 쓰인 분류명을 분류 목록으로 가져오기 (중복 무시) */ + @Transactional + public List importFromEntries(Long memberId) { + for (String type : new String[]{"INCOME", "EXPENSE"}) { + for (String name : entryMapper.findDistinctCategories(memberId, type)) { + categoryMapper.insertIgnore(AccountCategory.builder() + .memberId(memberId).type(type).name(name).sortOrder(0).build()); + } + } + return list(memberId); + } + + private AccountCategory mustFind(Long id, Long memberId) { + AccountCategory c = categoryMapper.findByIdAndMember(id, memberId); + if (c == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "분류를 찾을 수 없습니다."); + } + return c; + } +} diff --git a/src/main/java/com/sb/web/account/service/InvestService.java b/src/main/java/com/sb/web/account/service/InvestService.java new file mode 100644 index 0000000..919889c --- /dev/null +++ b/src/main/java/com/sb/web/account/service/InvestService.java @@ -0,0 +1,235 @@ +package com.sb.web.account.service; + +import com.sb.web.account.domain.InvestHolding; +import com.sb.web.account.domain.InvestTrade; +import com.sb.web.account.domain.Wallet; +import com.sb.web.account.dto.HoldingRequest; +import com.sb.web.account.dto.HoldingResponse; +import com.sb.web.account.dto.TradeRequest; +import com.sb.web.account.dto.TradeResponse; +import com.sb.web.account.mapper.InvestMapper; +import com.sb.web.account.mapper.WalletMapper; +import com.sb.web.common.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 투자 포트폴리오(C). 보유종목·매매이력 관리와 평단/실현·평가손익 산출. + * 모든 데이터는 memberId(소유자)로 격리한다. + */ +@Service +@RequiredArgsConstructor +public class InvestService { + + private final InvestMapper investMapper; + private final WalletMapper walletMapper; + + /* ===================== 보유 종목 ===================== */ + + public List listHoldings(Long memberId, Long walletId) { + requireInvestWallet(walletId, memberId); + Map> tradesByHolding = tradesByHolding(memberId); + return investMapper.findHoldingsByWallet(memberId, walletId).stream() + .map(h -> toResponse(h, tradesByHolding.getOrDefault(h.getId(), List.of()))) + .toList(); + } + + @Transactional + public HoldingResponse createHolding(HoldingRequest req, Long memberId) { + requireInvestWallet(req.getWalletId(), memberId); + InvestHolding h = InvestHolding.builder() + .memberId(memberId) + .walletId(req.getWalletId()) + .name(req.getName().trim()) + .ticker(blankToNull(req.getTicker())) + .currentPrice(req.getCurrentPrice()) + .build(); + investMapper.insertHolding(h); + return toResponse(h, List.of()); + } + + @Transactional + public HoldingResponse updateHolding(Long id, HoldingRequest req, Long memberId) { + InvestHolding h = mustHolding(id, memberId); + h.setName(req.getName().trim()); + h.setTicker(blankToNull(req.getTicker())); + h.setCurrentPrice(req.getCurrentPrice()); + investMapper.updateHolding(h); + return toResponse(h, investMapper.findTradesByHolding(id, memberId)); + } + + @Transactional + public void deleteHolding(Long id, Long memberId) { + mustHolding(id, memberId); + investMapper.deleteTradesByHolding(id, memberId); + investMapper.deleteHolding(id, memberId); + } + + /* ===================== 매매 ===================== */ + + public List listTrades(Long holdingId, Long memberId) { + mustHolding(holdingId, memberId); + return investMapper.findTradesByHolding(holdingId, memberId).stream() + .map(TradeResponse::from) + .toList(); + } + + @Transactional + public TradeResponse addTrade(Long holdingId, TradeRequest req, Long memberId) { + mustHolding(holdingId, memberId); + String type = "SELL".equals(req.getTradeType()) ? "SELL" : "BUY"; + if ("SELL".equals(type)) { + Calc c = calc(investMapper.findTradesByHolding(holdingId, memberId)); + if (req.getQuantity() > c.quantity()) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "매도 수량이 보유수량(" + c.quantity() + ")을 초과합니다."); + } + } + InvestTrade t = InvestTrade.builder() + .memberId(memberId) + .holdingId(holdingId) + .tradeType(type) + .tradeDate(req.getTradeDate()) + .quantity(req.getQuantity()) + .price(req.getPrice()) + .fee(req.getFee() != null ? req.getFee() : 0L) + .build(); + investMapper.insertTrade(t); + return TradeResponse.from(t); + } + + @Transactional + public void deleteTrade(Long id, Long memberId) { + if (investMapper.findTradeByIdAndMember(id, memberId) == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "매매 내역을 찾을 수 없습니다."); + } + investMapper.deleteTrade(id, memberId); + } + + /* ===================== 지갑 단위 평가 (AccountService 연동) ===================== */ + + /** 투자(INVEST) 지갑별 {예수금 증감(cashDelta), 주식 평가금액(stockEval)} */ + public Map valuationByWallet(Long memberId) { + Map> tradesByHolding = tradesByHolding(memberId); + Map result = new HashMap<>(); + for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) { + List trades = tradesByHolding.getOrDefault(h.getId(), List.of()); + Calc c = calc(trades); + long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : c.avgPrice(); + WalletInvest wi = result.computeIfAbsent(h.getWalletId(), k -> new WalletInvest()); + wi.cashDelta += cashDelta(trades); + wi.stockEval += c.quantity() * evalPrice; + } + return result; + } + + /** 투자 지갑별 가치 holder */ + public static class WalletInvest { + public long cashDelta; // 매매로 인한 예수금 증감 (Σ매도대금 − Σ매수금액) + public long stockEval; // 주식 평가금액 (Σ수량×평가단가) + } + + /* ===================== 내부 ===================== */ + + private Map> tradesByHolding(Long memberId) { + Map> map = new HashMap<>(); + for (InvestTrade t : investMapper.findTradesByMember(memberId)) { + map.computeIfAbsent(t.getHoldingId(), k -> new java.util.ArrayList<>()).add(t); + } + return map; + } + + /** 매매이력(시간순)으로 보유수량·원가·실현손익 산출 */ + private Calc calc(List trades) { + long qty = 0, costBasis = 0, realized = 0; + for (InvestTrade t : trades) { + long amount = t.getQuantity() * t.getPrice(); + long fee = t.getFee() != null ? t.getFee() : 0L; + if ("BUY".equals(t.getTradeType())) { + qty += t.getQuantity(); + costBasis += amount + fee; + } else { // SELL + long costRemoved = (qty > 0 && t.getQuantity() < qty) + ? Math.round(costBasis * (double) t.getQuantity() / qty) + : costBasis; // 전량 매도 시 잔여 원가 전부 제거(반올림 잔차 방지) + long proceeds = amount - fee; + realized += proceeds - costRemoved; + costBasis -= costRemoved; + qty -= t.getQuantity(); + if (qty <= 0) { + qty = 0; + costBasis = 0; + } + } + } + return new Calc(qty, costBasis, realized); + } + + private record Calc(long quantity, long costBasis, long realized) { + long avgPrice() { + return quantity > 0 ? Math.round(costBasis / (double) quantity) : 0; + } + } + + /** 매매로 인한 예수금 증감 = −Σ매수(금액+수수료) + Σ매도(금액−수수료) */ + private long cashDelta(List trades) { + long cashDelta = 0; + for (InvestTrade t : trades) { + long amount = t.getQuantity() * t.getPrice(); + long fee = t.getFee() != null ? t.getFee() : 0L; + cashDelta += "BUY".equals(t.getTradeType()) ? -(amount + fee) : (amount - fee); + } + return cashDelta; + } + + private HoldingResponse toResponse(InvestHolding h, List trades) { + Calc c = calc(trades); + long avgPrice = c.avgPrice(); + long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : avgPrice; + long evalValue = c.quantity() * evalPrice; + long evalGain = evalValue - c.costBasis(); + Double returnPct = c.costBasis() > 0 ? Math.round(evalGain * 1000.0 / c.costBasis()) / 10.0 : null; + return HoldingResponse.builder() + .id(h.getId()) + .walletId(h.getWalletId()) + .name(h.getName()) + .ticker(h.getTicker()) + .currentPrice(h.getCurrentPrice()) + .quantity(c.quantity()) + .avgPrice(avgPrice) + .costBasis(c.costBasis()) + .evalValue(evalValue) + .evalGain(evalGain) + .returnPct(returnPct) + .realizedPL(c.realized) + .build(); + } + + private void requireInvestWallet(Long walletId, Long memberId) { + Wallet w = walletMapper.findByIdAndMember(walletId, memberId); + if (w == null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "투자계좌가 올바르지 않습니다."); + } + if (!"INVEST".equals(w.getType())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "투자(INVEST) 계좌만 종목을 보유할 수 있습니다."); + } + } + + private InvestHolding mustHolding(Long id, Long memberId) { + InvestHolding h = investMapper.findHoldingByIdAndMember(id, memberId); + if (h == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "종목을 찾을 수 없습니다."); + } + return h; + } + + private String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } +} diff --git a/src/main/java/com/sb/web/account/service/RecurringService.java b/src/main/java/com/sb/web/account/service/RecurringService.java new file mode 100644 index 0000000..be3b37b --- /dev/null +++ b/src/main/java/com/sb/web/account/service/RecurringService.java @@ -0,0 +1,178 @@ +package com.sb.web.account.service; + +import com.sb.web.account.domain.AccountEntry; +import com.sb.web.account.domain.Recurring; +import com.sb.web.account.dto.RecurringRequest; +import com.sb.web.account.dto.RecurringResponse; +import com.sb.web.account.mapper.AccountEntryMapper; +import com.sb.web.account.mapper.RecurringMapper; +import com.sb.web.common.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; + +/** + * 정기/반복 거래 서비스. CRUD + 밀린 회차 자동 생성(중복 없이). + */ +@Service +@RequiredArgsConstructor +public class RecurringService { + + private final RecurringMapper recurringMapper; + private final AccountEntryMapper entryMapper; + + public List list(Long memberId) { + return recurringMapper.findByMember(memberId).stream().map(this::toResponse).toList(); + } + + @Transactional + public RecurringResponse create(RecurringRequest req, Long memberId) { + Recurring r = toEntity(req); + r.setMemberId(memberId); + recurringMapper.insert(r); + return toResponse(recurringMapper.findByIdAndMember(r.getId(), memberId)); + } + + @Transactional + public RecurringResponse update(Long id, RecurringRequest req, Long memberId) { + mustFind(id, memberId); + Recurring r = toEntity(req); + r.setId(id); + r.setMemberId(memberId); + recurringMapper.update(r); + return toResponse(recurringMapper.findByIdAndMember(id, memberId)); + } + + @Transactional + public void delete(Long id, Long memberId) { + mustFind(id, memberId); + recurringMapper.delete(id, memberId); + } + + /** 활성 규칙의 밀린 회차를 오늘까지 생성 (중복 없음). 생성 건수 반환 */ + @Transactional + public int run(Long memberId) { + LocalDate today = LocalDate.now(); + int count = 0; + for (Recurring r : recurringMapper.findActiveByMember(memberId)) { + LocalDate cursor = r.getLastRunDate() != null ? r.getLastRunDate() : r.getStartDate().minusDays(1); + LocalDate next = nextOccurrence(r, cursor); + LocalDate generatedUpTo = null; + int guard = 0; + while (next != null && !next.isAfter(today) + && (r.getEndDate() == null || !next.isAfter(r.getEndDate())) + && guard++ < 1000) { + entryMapper.insert(buildEntry(r, next, memberId)); + generatedUpTo = next; + count++; + next = nextOccurrence(r, next); + } + if (generatedUpTo != null) { + recurringMapper.updateLastRun(r.getId(), generatedUpTo); + } + } + return count; + } + + /* ===================== 내부 ===================== */ + + private AccountEntry buildEntry(Recurring r, LocalDate date, Long memberId) { + boolean transfer = "TRANSFER".equals(r.getType()); + return AccountEntry.builder() + .memberId(memberId) + .entryDate(date) + .type(r.getType()) + .category(transfer ? null : r.getCategory()) + .amount(r.getAmount()) + .memo(r.getMemo()) + .walletId(r.getWalletId()) + .toWalletId(transfer ? r.getToWalletId() : null) + .build(); + } + + /** from 이후 첫 발생일 (없으면 null) */ + private LocalDate nextOccurrence(Recurring r, LocalDate from) { + return switch (r.getFrequency()) { + case "DAILY" -> from.plusDays(1); + case "WEEKLY" -> { + int dow = r.getDayOfWeek() != null ? r.getDayOfWeek() : 1; + LocalDate d = from.plusDays(1); + while (d.getDayOfWeek().getValue() != dow) d = d.plusDays(1); + yield d; + } + case "MONTHLY" -> { + int dom = r.getDayOfMonth() != null ? r.getDayOfMonth() : 1; + YearMonth ym = YearMonth.from(from); + LocalDate c = clamp(ym, dom); + if (!c.isAfter(from)) c = clamp(ym.plusMonths(1), dom); + yield c; + } + case "YEARLY" -> { + int moy = r.getMonthOfYear() != null ? r.getMonthOfYear() : 1; + int dom = r.getDayOfMonth() != null ? r.getDayOfMonth() : 1; + LocalDate c = clamp(YearMonth.of(from.getYear(), moy), dom); + if (!c.isAfter(from)) c = clamp(YearMonth.of(from.getYear() + 1, moy), dom); + yield c; + } + default -> null; + }; + } + + private LocalDate clamp(YearMonth ym, int dom) { + return ym.atDay(Math.min(dom, ym.lengthOfMonth())); + } + + private Recurring toEntity(RecurringRequest req) { + return Recurring.builder() + .title(req.getTitle().trim()) + .type(req.getType()) + .amount(req.getAmount()) + .category("TRANSFER".equals(req.getType()) ? null : blankToNull(req.getCategory())) + .memo(blankToNull(req.getMemo())) + .walletId(req.getWalletId()) + .toWalletId("TRANSFER".equals(req.getType()) ? req.getToWalletId() : null) + .frequency(req.getFrequency()) + .dayOfMonth(req.getDayOfMonth()) + .dayOfWeek(req.getDayOfWeek()) + .monthOfYear(req.getMonthOfYear()) + .startDate(req.getStartDate()) + .endDate(req.getEndDate()) + .active(req.isActive()) + .build(); + } + + private RecurringResponse toResponse(Recurring r) { + LocalDate cursor = r.getLastRunDate() != null ? r.getLastRunDate() : r.getStartDate().minusDays(1); + LocalDate next = nextOccurrence(r, cursor); + if (next != null && r.getEndDate() != null && next.isAfter(r.getEndDate())) { + next = null; + } + return RecurringResponse.builder() + .id(r.getId()).title(r.getTitle()).type(r.getType()).amount(r.getAmount()) + .category(r.getCategory()).memo(r.getMemo()) + .walletId(r.getWalletId()).walletName(r.getWalletName()) + .toWalletId(r.getToWalletId()).toWalletName(r.getToWalletName()) + .frequency(r.getFrequency()).dayOfMonth(r.getDayOfMonth()) + .dayOfWeek(r.getDayOfWeek()).monthOfYear(r.getMonthOfYear()) + .startDate(r.getStartDate()).endDate(r.getEndDate()).lastRunDate(r.getLastRunDate()) + .nextDate(next).active(Boolean.TRUE.equals(r.getActive())) + .build(); + } + + private Recurring mustFind(Long id, Long memberId) { + Recurring r = recurringMapper.findByIdAndMember(id, memberId); + if (r == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "정기 거래를 찾을 수 없습니다."); + } + return r; + } + + private String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } +} diff --git a/src/main/java/com/sb/web/admin/controller/AdminTagController.java b/src/main/java/com/sb/web/admin/controller/AdminTagController.java new file mode 100644 index 0000000..ed51555 --- /dev/null +++ b/src/main/java/com/sb/web/admin/controller/AdminTagController.java @@ -0,0 +1,83 @@ +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; +import com.sb.web.board.dto.TagResponse; +import com.sb.web.board.service.TagService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 관리자 태그 관리 API. (/api/admin/** — AdminInterceptor 로 ADMIN 권한 필요) + * GET /tag-categories 카테고리+태그 목록 + * POST /tag-categories 카테고리 생성 + * PUT /tag-categories/{id} 카테고리 수정 + * DELETE /tag-categories/{id} 카테고리 삭제(소속 태그 포함) + * POST /tags 태그 생성 + * PUT /tags/{id} 태그 수정 + * DELETE /tags/{id} 태그 삭제 + */ +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminTagController { + + private final TagService tagService; + + @GetMapping("/tag-categories") + public List categories() { + return tagService.listGrouped(); + } + + @PostMapping("/tag-categories") + public ResponseEntity createCategory(@Valid @RequestBody TagCategoryRequest req) { + return ResponseEntity.status(HttpStatus.CREATED).body(tagService.createCategory(req)); + } + + @PutMapping("/tag-categories/{id}") + public TagCategoryResponse updateCategory(@PathVariable Long id, @Valid @RequestBody TagCategoryRequest req) { + return tagService.updateCategory(id, req); + } + + @DeleteMapping("/tag-categories/{id}") + public ResponseEntity deleteCategory(@PathVariable Long id) { + tagService.deleteCategory(id); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/tags") + public ResponseEntity createTag(@Valid @RequestBody TagRequest req) { + return ResponseEntity.status(HttpStatus.CREATED).body(tagService.createTag(req)); + } + + @PutMapping("/tags/{id}") + public TagResponse updateTag(@PathVariable Long id, @Valid @RequestBody TagRequest req) { + return tagService.updateTag(id, req); + } + + @DeleteMapping("/tags/{id}") + public ResponseEntity deleteTag(@PathVariable Long id) { + 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()); + } +} diff --git a/src/main/java/com/sb/web/auth/controller/AuthController.java b/src/main/java/com/sb/web/auth/controller/AuthController.java new file mode 100644 index 0000000..f217512 --- /dev/null +++ b/src/main/java/com/sb/web/auth/controller/AuthController.java @@ -0,0 +1,51 @@ +package com.sb.web.auth.controller; + +import com.sb.web.auth.dto.LoginRequest; +import com.sb.web.auth.dto.LoginResponse; +import com.sb.web.auth.dto.MemberResponse; +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.dto.SignupRequest; +import com.sb.web.auth.service.AuthService; +import com.sb.web.auth.web.AuthInterceptor; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 인증 API. + * POST /api/auth/signup 회원가입 (비보호) + * POST /api/auth/login 로그인 (비보호) + * POST /api/auth/logout 로그아웃 (보호) + * GET /api/auth/me 내 정보 (보호) + */ +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/signup") + public ResponseEntity signup(@Valid @RequestBody SignupRequest req) { + return ResponseEntity.status(HttpStatus.CREATED).body(authService.signup(req)); + } + + @PostMapping("/login") + public LoginResponse login(@Valid @RequestBody LoginRequest req) { + return authService.login(req); + } + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request) { + authService.logout(AuthInterceptor.resolveToken(request)); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/me") + public SessionUser me(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return current; + } +} diff --git a/src/main/java/com/sb/web/auth/domain/Member.java b/src/main/java/com/sb/web/auth/domain/Member.java new file mode 100644 index 0000000..d193699 --- /dev/null +++ b/src/main/java/com/sb/web/auth/domain/Member.java @@ -0,0 +1,32 @@ +package com.sb.web.auth.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 회원 도메인. member 테이블과 매핑. + * 로컬(아이디/비밀번호) + 소셜(네이버 등) 로그인을 함께 수용한다. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Member implements Serializable { + + private Long id; + private String loginId; // 로컬 로그인 아이디 (소셜 전용은 null) + private String password; // BCrypt 해시 (소셜 전용은 null) + private String name; + private String email; + private String provider; // LOCAL / NAVER + private String providerId; // 소셜 제공자 측 고유 ID + private String role; // USER / ADMIN + private String status; // ACTIVE / SUSPENDED / WITHDRAWN + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/sb/web/auth/dto/LoginRequest.java b/src/main/java/com/sb/web/auth/dto/LoginRequest.java new file mode 100644 index 0000000..0c11965 --- /dev/null +++ b/src/main/java/com/sb/web/auth/dto/LoginRequest.java @@ -0,0 +1,17 @@ +package com.sb.web.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 로컬 로그인 요청 (아이디/비밀번호). + */ +@Data +public class LoginRequest { + + @NotBlank(message = "아이디를 입력하세요.") + private String loginId; + + @NotBlank(message = "비밀번호를 입력하세요.") + private String password; +} diff --git a/src/main/java/com/sb/web/auth/dto/LoginResponse.java b/src/main/java/com/sb/web/auth/dto/LoginResponse.java new file mode 100644 index 0000000..40a16ce --- /dev/null +++ b/src/main/java/com/sb/web/auth/dto/LoginResponse.java @@ -0,0 +1,17 @@ +package com.sb.web.auth.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 로그인 성공 응답. 프론트는 token 을 localStorage 에 저장하고 + * 이후 요청 시 Authorization: Bearer {token} 으로 전송한다. + */ +@Data +@Builder +public class LoginResponse { + + private String token; + private long expiresInSeconds; + private MemberResponse member; +} diff --git a/src/main/java/com/sb/web/auth/dto/MemberResponse.java b/src/main/java/com/sb/web/auth/dto/MemberResponse.java new file mode 100644 index 0000000..d5af7db --- /dev/null +++ b/src/main/java/com/sb/web/auth/dto/MemberResponse.java @@ -0,0 +1,31 @@ +package com.sb.web.auth.dto; + +import com.sb.web.auth.domain.Member; +import lombok.Builder; +import lombok.Data; + +/** + * 클라이언트로 내려보내는 회원 정보 (비밀번호 등 민감 정보 제외). + */ +@Data +@Builder +public class MemberResponse { + + private Long id; + private String loginId; + private String name; + private String email; + private String provider; + private String role; + + public static MemberResponse from(Member m) { + return MemberResponse.builder() + .id(m.getId()) + .loginId(m.getLoginId()) + .name(m.getName()) + .email(m.getEmail()) + .provider(m.getProvider()) + .role(m.getRole()) + .build(); + } +} diff --git a/src/main/java/com/sb/web/auth/dto/SessionUser.java b/src/main/java/com/sb/web/auth/dto/SessionUser.java new file mode 100644 index 0000000..7cf03b3 --- /dev/null +++ b/src/main/java/com/sb/web/auth/dto/SessionUser.java @@ -0,0 +1,36 @@ +package com.sb.web.auth.dto; + +import com.sb.web.auth.domain.Member; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * Redis 세션에 저장되는 인증 주체 정보 (경량). + * 인터셉터 통과 후 컨트롤러에서 현재 로그인 사용자로 사용한다. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SessionUser implements Serializable { + + private Long id; + private String loginId; + private String name; + private String role; + private String provider; + + public static SessionUser from(Member m) { + return SessionUser.builder() + .id(m.getId()) + .loginId(m.getLoginId()) + .name(m.getName()) + .role(m.getRole()) + .provider(m.getProvider()) + .build(); + } +} diff --git a/src/main/java/com/sb/web/auth/dto/SignupRequest.java b/src/main/java/com/sb/web/auth/dto/SignupRequest.java new file mode 100644 index 0000000..8e9b9d9 --- /dev/null +++ b/src/main/java/com/sb/web/auth/dto/SignupRequest.java @@ -0,0 +1,27 @@ +package com.sb.web.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 로컬 회원가입 요청. + */ +@Data +public class SignupRequest { + + @NotBlank(message = "아이디를 입력하세요.") + @Size(min = 4, max = 50, message = "아이디는 4~50자여야 합니다.") + private String loginId; + + @NotBlank(message = "비밀번호를 입력하세요.") + @Size(min = 8, max = 64, message = "비밀번호는 8~64자여야 합니다.") + private String password; + + @NotBlank(message = "이름을 입력하세요.") + private String name; + + @Email(message = "이메일 형식이 올바르지 않습니다.") + private String email; +} diff --git a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java new file mode 100644 index 0000000..56406d0 --- /dev/null +++ b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java @@ -0,0 +1,23 @@ +package com.sb.web.auth.mapper; + +import com.sb.web.auth.domain.Member; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 회원 MyBatis 매퍼. SQL 은 resources/mapper/MemberMapper.xml 에 정의. + */ +@Mapper +public interface MemberMapper { + + Member findById(@Param("id") Long id); + + Member findByLoginId(@Param("loginId") String loginId); + + Member findByProvider(@Param("provider") String provider, + @Param("providerId") String providerId); + + int countByLoginId(@Param("loginId") String loginId); + + int insert(Member member); +} diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java new file mode 100644 index 0000000..bc0c210 --- /dev/null +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -0,0 +1,102 @@ +package com.sb.web.auth.service; + +import com.sb.web.auth.domain.Member; +import com.sb.web.auth.dto.LoginRequest; +import com.sb.web.auth.dto.LoginResponse; +import com.sb.web.auth.dto.MemberResponse; +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.dto.SignupRequest; +import com.sb.web.common.exception.ApiException; +import com.sb.web.auth.mapper.MemberMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.util.UUID; + +/** + * 인증 서비스. + * - 회원가입: BCrypt 해시 저장 + * - 로그인: 비밀번호 검증 후 세션 토큰 발급 → Redis 저장 + * - 세션: Redis 에서 토큰으로 조회(슬라이딩 만료), 로그아웃 시 삭제 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final MemberMapper memberMapper; + private final PasswordEncoder passwordEncoder; + private final RedisTemplate redisTemplate; + + private static final String SESSION_PREFIX = "session:"; + private static final Duration SESSION_TTL = Duration.ofMinutes(60); + + @Transactional + public MemberResponse signup(SignupRequest req) { + if (memberMapper.countByLoginId(req.getLoginId()) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다."); + } + Member member = Member.builder() + .loginId(req.getLoginId()) + .password(passwordEncoder.encode(req.getPassword())) + .name(req.getName()) + .email(req.getEmail()) + .provider("LOCAL") + .role("USER") + .status("ACTIVE") + .build(); + memberMapper.insert(member); + log.info("[signup] new member: {}", member.getLoginId()); + return MemberResponse.from(member); + } + + public LoginResponse login(LoginRequest req) { + Member member = memberMapper.findByLoginId(req.getLoginId()); + if (member == null + || member.getPassword() == null + || !passwordEncoder.matches(req.getPassword(), member.getPassword())) { + throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다."); + } + if (!"ACTIVE".equals(member.getStatus())) { + throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다."); + } + + String token = UUID.randomUUID().toString().replace("-", ""); + redisTemplate.opsForValue().set(SESSION_PREFIX + token, SessionUser.from(member), SESSION_TTL); + log.info("[login] {} (token issued)", member.getLoginId()); + + return LoginResponse.builder() + .token(token) + .expiresInSeconds(SESSION_TTL.getSeconds()) + .member(MemberResponse.from(member)) + .build(); + } + + /** + * 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다. + */ + public SessionUser getSession(String token) { + if (token == null || token.isBlank()) { + return null; + } + String key = SESSION_PREFIX + token; + Object cached = redisTemplate.opsForValue().get(key); + if (cached instanceof SessionUser user) { + redisTemplate.expire(key, SESSION_TTL); + return user; + } + return null; + } + + public void logout(String token) { + if (token != null && !token.isBlank()) { + redisTemplate.delete(SESSION_PREFIX + token); + } + } +} diff --git a/src/main/java/com/sb/web/auth/web/AdminInterceptor.java b/src/main/java/com/sb/web/auth/web/AdminInterceptor.java new file mode 100644 index 0000000..5754613 --- /dev/null +++ b/src/main/java/com/sb/web/auth/web/AdminInterceptor.java @@ -0,0 +1,36 @@ +package com.sb.web.auth.web; + +import com.sb.web.auth.dto.SessionUser; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.nio.charset.StandardCharsets; + +/** + * 관리자 전용 경로 보호. AuthInterceptor 가 먼저 실행되어 세팅한 SessionUser 의 role 을 검사한다. + */ +@Component +public class AdminInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + if (HttpMethod.OPTIONS.matches(request.getMethod())) { + return true; + } + Object attr = request.getAttribute(AuthInterceptor.CURRENT_MEMBER); + if (attr instanceof SessionUser user && "ADMIN".equals(user.getRole())) { + return true; + } + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write("{\"status\":403,\"message\":\"관리자 권한이 필요합니다.\"}"); + return false; + } +} diff --git a/src/main/java/com/sb/web/auth/web/AuthInterceptor.java b/src/main/java/com/sb/web/auth/web/AuthInterceptor.java new file mode 100644 index 0000000..026ec81 --- /dev/null +++ b/src/main/java/com/sb/web/auth/web/AuthInterceptor.java @@ -0,0 +1,59 @@ +package com.sb.web.auth.web; + +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.nio.charset.StandardCharsets; + +/** + * 보호 경로 접근 시 Authorization: Bearer {token} 을 검증한다. + * 유효한 세션이면 SessionUser 를 요청 속성으로 전달, 아니면 401 JSON 응답. + */ +@Component +@RequiredArgsConstructor +public class AuthInterceptor implements HandlerInterceptor { + + public static final String CURRENT_MEMBER = "currentMember"; + + private final AuthService authService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + // CORS preflight 는 통과 + if (HttpMethod.OPTIONS.matches(request.getMethod())) { + return true; + } + SessionUser user = authService.getSession(resolveToken(request)); + if (user == null) { + writeUnauthorized(response); + return false; + } + request.setAttribute(CURRENT_MEMBER, user); + return true; + } + + public static String resolveToken(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header != null && header.startsWith("Bearer ")) { + return header.substring(7); + } + return null; + } + + private void writeUnauthorized(HttpServletResponse response) throws Exception { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write("{\"status\":401,\"message\":\"로그인이 필요합니다.\"}"); + } +} diff --git a/src/main/java/com/sb/web/board/controller/BoardController.java b/src/main/java/com/sb/web/board/controller/BoardController.java new file mode 100644 index 0000000..095fb6b --- /dev/null +++ b/src/main/java/com/sb/web/board/controller/BoardController.java @@ -0,0 +1,127 @@ +package com.sb.web.board.controller; + +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.web.AuthInterceptor; +import com.sb.web.board.dto.CommentRequest; +import com.sb.web.board.dto.CommentResponse; +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.TagCategoryResponse; +import com.sb.web.board.service.BoardService; +import com.sb.web.board.service.TagService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 자유게시판 API. (전체 로그인 필요 — WebConfig 인터셉터로 /api/board/** 보호) + * GET /api/board/posts 목록(페이징·태그·검색) + * GET /api/board/posts/{id} 상세(조회수 증가) + * POST /api/board/posts 작성 + * PUT /api/board/posts/{id} 수정(작성자/관리자) + * DELETE /api/board/posts/{id} 삭제(작성자/관리자) + * GET /api/board/tags 전체 태그 + * POST /api/board/posts/{id}/comments 댓글 작성 + * DELETE /api/board/comments/{commentId} 댓글 삭제(작성자/관리자) + */ +@RestController +@RequestMapping("/api/board") +@RequiredArgsConstructor +public class BoardController { + + private final BoardService boardService; + private final TagService tagService; + + @GetMapping("/posts") + public PageResponse list( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String tag, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String searchType) { + return boardService.list(page, size, tag, keyword, searchType); + } + + @GetMapping("/posts/{id}") + public PostDetail get( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return boardService.get(id, current); + } + + @PostMapping("/posts") + public ResponseEntity create( + @Valid @RequestBody PostRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return ResponseEntity.status(HttpStatus.CREATED).body(boardService.create(req, current)); + } + + @PutMapping("/posts/{id}") + public PostDetail update( + @PathVariable Long id, + @Valid @RequestBody PostRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return boardService.update(id, req, current); + } + + @DeleteMapping("/posts/{id}") + public ResponseEntity delete( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + boardService.delete(id, current); + return ResponseEntity.noContent().build(); + } + + /** 열람 제한 (관리자) */ + @PostMapping("/posts/{id}/block") + public ResponseEntity block( + @PathVariable Long id, + @RequestBody(required = false) Map body, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + boardService.setBlocked(id, true, body != null ? body.get("reason") : null, current); + return ResponseEntity.noContent().build(); + } + + /** 열람 제한 해제 (관리자) */ + @PostMapping("/posts/{id}/unblock") + public ResponseEntity unblock( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + boardService.setBlocked(id, false, null, current); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/tags") + public List tags() { + return boardService.allTags(); + } + + /** 글 작성 시 선택 가능한 태그 (게시판 설정 카테고리로 제한) */ + @GetMapping("/tag-groups") + public List tagGroups() { + return tagService.listWritableGroups(); + } + + @PostMapping("/posts/{id}/comments") + public ResponseEntity addComment( + @PathVariable Long id, + @Valid @RequestBody CommentRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return ResponseEntity.status(HttpStatus.CREATED).body(boardService.addComment(id, req, current)); + } + + @DeleteMapping("/comments/{commentId}") + public ResponseEntity deleteComment( + @PathVariable Long commentId, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + boardService.deleteComment(commentId, current); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sb/web/board/domain/Comment.java b/src/main/java/com/sb/web/board/domain/Comment.java new file mode 100644 index 0000000..e9180ab --- /dev/null +++ b/src/main/java/com/sb/web/board/domain/Comment.java @@ -0,0 +1,26 @@ +package com.sb.web.board.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 댓글 도메인. comment 테이블과 매핑. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Comment implements Serializable { + + private Long id; + private Long postId; + private Long authorId; + private String authorName; + private String content; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sb/web/board/domain/Post.java b/src/main/java/com/sb/web/board/domain/Post.java new file mode 100644 index 0000000..9aca8f1 --- /dev/null +++ b/src/main/java/com/sb/web/board/domain/Post.java @@ -0,0 +1,30 @@ +package com.sb.web.board.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 게시글 도메인. post 테이블과 매핑. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Post implements Serializable { + + private Long id; + private String title; + private String content; + private Long authorId; + private String authorName; + private Integer viewCount; + private Boolean blocked; + private String blockReason; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/sb/web/board/domain/Tag.java b/src/main/java/com/sb/web/board/domain/Tag.java new file mode 100644 index 0000000..26a23f7 --- /dev/null +++ b/src/main/java/com/sb/web/board/domain/Tag.java @@ -0,0 +1,22 @@ +package com.sb.web.board.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 태그 도메인. tag 테이블과 매핑. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Tag implements Serializable { + + private Long id; + private String name; + private Long categoryId; +} diff --git a/src/main/java/com/sb/web/board/domain/TagCategory.java b/src/main/java/com/sb/web/board/domain/TagCategory.java new file mode 100644 index 0000000..4932062 --- /dev/null +++ b/src/main/java/com/sb/web/board/domain/TagCategory.java @@ -0,0 +1,24 @@ +package com.sb.web.board.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 태그 카테고리(그룹). tag_category 테이블과 매핑. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TagCategory implements Serializable { + + private Long id; + private String name; + private Integer sortOrder; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sb/web/board/dto/BoardSettingRequest.java b/src/main/java/com/sb/web/board/dto/BoardSettingRequest.java new file mode 100644 index 0000000..60d81b4 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/BoardSettingRequest.java @@ -0,0 +1,11 @@ +package com.sb.web.board.dto; + +import lombok.Data; + +/** + * 게시판 설정 변경 요청 — 사용할 태그 카테고리(null 이면 제한 없음). + */ +@Data +public class BoardSettingRequest { + private Long tagCategoryId; +} diff --git a/src/main/java/com/sb/web/board/dto/BoardSettingResponse.java b/src/main/java/com/sb/web/board/dto/BoardSettingResponse.java new file mode 100644 index 0000000..5538521 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/BoardSettingResponse.java @@ -0,0 +1,13 @@ +package com.sb.web.board.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 게시판 설정 응답. + */ +@Data +@Builder +public class BoardSettingResponse { + private Long tagCategoryId; +} diff --git a/src/main/java/com/sb/web/board/dto/CommentRequest.java b/src/main/java/com/sb/web/board/dto/CommentRequest.java new file mode 100644 index 0000000..ac82b78 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/CommentRequest.java @@ -0,0 +1,16 @@ +package com.sb.web.board.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 댓글 작성 요청. + */ +@Data +public class CommentRequest { + + @NotBlank(message = "댓글 내용을 입력하세요.") + @Size(max = 1000, message = "댓글은 1000자 이내여야 합니다.") + private String content; +} diff --git a/src/main/java/com/sb/web/board/dto/CommentResponse.java b/src/main/java/com/sb/web/board/dto/CommentResponse.java new file mode 100644 index 0000000..2e1d644 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/CommentResponse.java @@ -0,0 +1,31 @@ +package com.sb.web.board.dto; + +import com.sb.web.board.domain.Comment; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 댓글 응답. + */ +@Data +@Builder +public class CommentResponse { + + private Long id; + private Long authorId; + private String authorName; + private String content; + private LocalDateTime createdAt; + + public static CommentResponse from(Comment c) { + return CommentResponse.builder() + .id(c.getId()) + .authorId(c.getAuthorId()) + .authorName(c.getAuthorName()) + .content(c.getContent()) + .createdAt(c.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/sb/web/board/dto/PageResponse.java b/src/main/java/com/sb/web/board/dto/PageResponse.java new file mode 100644 index 0000000..a4f2b9c --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/PageResponse.java @@ -0,0 +1,31 @@ +package com.sb.web.board.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * 페이지네이션 응답 래퍼. + */ +@Data +@Builder +public class PageResponse { + + private List content; + private int page; // 1-base + private int size; + private long totalElements; + private int totalPages; + + public static PageResponse of(List content, int page, int size, long total) { + int totalPages = size > 0 ? (int) Math.ceil((double) total / size) : 0; + return PageResponse.builder() + .content(content) + .page(page) + .size(size) + .totalElements(total) + .totalPages(totalPages) + .build(); + } +} diff --git a/src/main/java/com/sb/web/board/dto/PostDetail.java b/src/main/java/com/sb/web/board/dto/PostDetail.java new file mode 100644 index 0000000..73e0f3a --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/PostDetail.java @@ -0,0 +1,60 @@ +package com.sb.web.board.dto; + +import com.sb.web.board.domain.Post; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 게시글 상세 (태그 + 댓글 포함). + */ +@Data +@Builder +public class PostDetail { + + private Long id; + private String title; + private String content; + private Long authorId; + private String authorName; + private Integer viewCount; + private Boolean blocked; + private String blockReason; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List tags; + private List comments; + + public static PostDetail of(Post p, List tags, List comments) { + return PostDetail.builder() + .id(p.getId()) + .title(p.getTitle()) + .content(p.getContent()) + .authorId(p.getAuthorId()) + .authorName(p.getAuthorName()) + .viewCount(p.getViewCount()) + .blocked(Boolean.TRUE.equals(p.getBlocked())) + .blockReason(p.getBlockReason()) + .createdAt(p.getCreatedAt()) + .updatedAt(p.getUpdatedAt()) + .tags(tags) + .comments(comments) + .build(); + } + + /** 비관리자에게 보여줄 열람 제한 상세 (본문·댓글·태그 숨김) */ + public static PostDetail restricted(Post p) { + return PostDetail.builder() + .id(p.getId()) + .title(p.getTitle()) + .authorName(p.getAuthorName()) + .viewCount(p.getViewCount()) + .blocked(true) + .createdAt(p.getCreatedAt()) + .tags(List.of()) + .comments(List.of()) + .build(); + } +} diff --git a/src/main/java/com/sb/web/board/dto/PostRequest.java b/src/main/java/com/sb/web/board/dto/PostRequest.java new file mode 100644 index 0000000..405de76 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/PostRequest.java @@ -0,0 +1,24 @@ +package com.sb.web.board.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** + * 게시글 작성/수정 요청. + */ +@Data +public class PostRequest { + + @NotBlank(message = "제목을 입력하세요.") + @Size(max = 200, message = "제목은 200자 이내여야 합니다.") + private String title; + + @NotBlank(message = "내용을 입력하세요.") + private String content; + + /** 선택한 태그 ID 목록 (DB에 등록된 태그만, 선택) */ + private List tagIds; +} diff --git a/src/main/java/com/sb/web/board/dto/PostSummary.java b/src/main/java/com/sb/web/board/dto/PostSummary.java new file mode 100644 index 0000000..6137eed --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/PostSummary.java @@ -0,0 +1,22 @@ +package com.sb.web.board.dto; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 게시글 목록 항목 (요약). + */ +@Data +public class PostSummary { + + private Long id; + private String title; + private String authorName; + private Integer viewCount; + private Integer commentCount; + private Boolean blocked; + private LocalDateTime createdAt; + private List tags; +} diff --git a/src/main/java/com/sb/web/board/dto/TagCategoryRequest.java b/src/main/java/com/sb/web/board/dto/TagCategoryRequest.java new file mode 100644 index 0000000..08d7330 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/TagCategoryRequest.java @@ -0,0 +1,18 @@ +package com.sb.web.board.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 태그 카테고리 생성/수정 요청. + */ +@Data +public class TagCategoryRequest { + + @NotBlank(message = "카테고리 이름을 입력하세요.") + @Size(max = 50, message = "카테고리 이름은 50자 이내여야 합니다.") + private String name; + + private Integer sortOrder; +} diff --git a/src/main/java/com/sb/web/board/dto/TagCategoryResponse.java b/src/main/java/com/sb/web/board/dto/TagCategoryResponse.java new file mode 100644 index 0000000..6768780 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/TagCategoryResponse.java @@ -0,0 +1,29 @@ +package com.sb.web.board.dto; + +import com.sb.web.board.domain.TagCategory; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * 태그 카테고리 응답 (소속 태그 포함). + */ +@Data +@Builder +public class TagCategoryResponse { + + private Long id; + private String name; + private Integer sortOrder; + private List tags; + + public static TagCategoryResponse of(TagCategory c, List tags) { + return TagCategoryResponse.builder() + .id(c.getId()) + .name(c.getName()) + .sortOrder(c.getSortOrder()) + .tags(tags) + .build(); + } +} diff --git a/src/main/java/com/sb/web/board/dto/TagRequest.java b/src/main/java/com/sb/web/board/dto/TagRequest.java new file mode 100644 index 0000000..7c30b07 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/TagRequest.java @@ -0,0 +1,20 @@ +package com.sb.web.board.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 태그 생성/수정 요청 (관리자). + */ +@Data +public class TagRequest { + + @NotBlank(message = "태그 이름을 입력하세요.") + @Size(max = 50, message = "태그 이름은 50자 이내여야 합니다.") + private String name; + + @NotNull(message = "카테고리를 선택하세요.") + private Long categoryId; +} diff --git a/src/main/java/com/sb/web/board/dto/TagResponse.java b/src/main/java/com/sb/web/board/dto/TagResponse.java new file mode 100644 index 0000000..2815058 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/TagResponse.java @@ -0,0 +1,25 @@ +package com.sb.web.board.dto; + +import com.sb.web.board.domain.Tag; +import lombok.Builder; +import lombok.Data; + +/** + * 태그 응답. + */ +@Data +@Builder +public class TagResponse { + + private Long id; + private String name; + private Long categoryId; + + public static TagResponse from(Tag t) { + return TagResponse.builder() + .id(t.getId()) + .name(t.getName()) + .categoryId(t.getCategoryId()) + .build(); + } +} diff --git a/src/main/java/com/sb/web/board/mapper/BoardSettingMapper.java b/src/main/java/com/sb/web/board/mapper/BoardSettingMapper.java new file mode 100644 index 0000000..12c960c --- /dev/null +++ b/src/main/java/com/sb/web/board/mapper/BoardSettingMapper.java @@ -0,0 +1,13 @@ +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); +} diff --git a/src/main/java/com/sb/web/board/mapper/CommentMapper.java b/src/main/java/com/sb/web/board/mapper/CommentMapper.java new file mode 100644 index 0000000..4019144 --- /dev/null +++ b/src/main/java/com/sb/web/board/mapper/CommentMapper.java @@ -0,0 +1,21 @@ +package com.sb.web.board.mapper; + +import com.sb.web.board.domain.Comment; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface CommentMapper { + + List findByPostId(@Param("postId") Long postId); + + Comment findById(@Param("id") Long id); + + int insert(Comment comment); + + int delete(@Param("id") Long id); + + int deleteByPostId(@Param("postId") Long postId); +} diff --git a/src/main/java/com/sb/web/board/mapper/PostMapper.java b/src/main/java/com/sb/web/board/mapper/PostMapper.java new file mode 100644 index 0000000..3809091 --- /dev/null +++ b/src/main/java/com/sb/web/board/mapper/PostMapper.java @@ -0,0 +1,36 @@ +package com.sb.web.board.mapper; + +import com.sb.web.board.domain.Post; +import com.sb.web.board.dto.PostSummary; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface PostMapper { + + List findPage(@Param("offset") int offset, + @Param("size") int size, + @Param("tag") String tag, + @Param("keyword") String keyword, + @Param("searchType") String searchType); + + long countPage(@Param("tag") String tag, + @Param("keyword") String keyword, + @Param("searchType") String searchType); + + Post findById(@Param("id") Long id); + + int insert(Post post); + + int update(Post post); + + int delete(@Param("id") Long id); + + int increaseViewCount(@Param("id") Long id); + + int updateBlocked(@Param("id") Long id, + @Param("blocked") boolean blocked, + @Param("blockReason") String blockReason); +} diff --git a/src/main/java/com/sb/web/board/mapper/TagCategoryMapper.java b/src/main/java/com/sb/web/board/mapper/TagCategoryMapper.java new file mode 100644 index 0000000..893a8d6 --- /dev/null +++ b/src/main/java/com/sb/web/board/mapper/TagCategoryMapper.java @@ -0,0 +1,23 @@ +package com.sb.web.board.mapper; + +import com.sb.web.board.domain.TagCategory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface TagCategoryMapper { + + List findAll(); + + TagCategory findById(@Param("id") Long id); + + int countByName(@Param("name") String name, @Param("excludeId") Long excludeId); + + int insert(TagCategory category); + + int update(TagCategory category); + + int delete(@Param("id") Long id); +} diff --git a/src/main/java/com/sb/web/board/mapper/TagMapper.java b/src/main/java/com/sb/web/board/mapper/TagMapper.java new file mode 100644 index 0000000..c330e40 --- /dev/null +++ b/src/main/java/com/sb/web/board/mapper/TagMapper.java @@ -0,0 +1,40 @@ +package com.sb.web.board.mapper; + +import com.sb.web.board.domain.Tag; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface TagMapper { + + /* ----- 게시글 작성/표시 ----- */ + List findNamesByPostId(@Param("postId") Long postId); + + List findAllNames(); + + int insertPostTag(@Param("postId") Long postId, @Param("tagId") Long tagId); + + int deletePostTagsByPostId(@Param("postId") Long postId); + + /** 주어진 ID 중 실제 존재하는 태그 ID만 반환 (선택 검증용) */ + List findExistingIds(@Param("ids") List ids); + + /* ----- 관리자 태그 CRUD ----- */ + List findAll(); + + List findByCategoryId(@Param("categoryId") Long categoryId); + + Tag findById(@Param("id") Long id); + + int countByName(@Param("name") String name, @Param("excludeId") Long excludeId); + + int insert(Tag tag); + + int update(Tag tag); + + int delete(@Param("id") Long id); + + int deletePostTagsByTagId(@Param("tagId") Long tagId); +} diff --git a/src/main/java/com/sb/web/board/service/BoardService.java b/src/main/java/com/sb/web/board/service/BoardService.java new file mode 100644 index 0000000..0fe277c --- /dev/null +++ b/src/main/java/com/sb/web/board/service/BoardService.java @@ -0,0 +1,215 @@ +package com.sb.web.board.service; + +import com.sb.web.auth.dto.SessionUser; +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.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.mapper.CommentMapper; +import com.sb.web.board.mapper.PostMapper; +import com.sb.web.board.mapper.TagMapper; +import com.sb.web.common.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** + * 자유게시판 서비스. 글 CRUD, 태그(find-or-create), 조회수, 댓글, 권한 처리. + */ +@Service +@RequiredArgsConstructor +public class BoardService { + + private final PostMapper postMapper; + private final TagMapper tagMapper; + private final CommentMapper commentMapper; + private final RedisTemplate redisTemplate; + + /** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */ + private static final Duration VIEW_DEDUP_TTL = Duration.ofHours(24); + + /* ===================== 게시글 ===================== */ + + public PageResponse list(int page, int size, String tag, String keyword, String searchType) { + int p = Math.max(page, 1); + int s = Math.min(Math.max(size, 1), 100); + int offset = (p - 1) * s; + String st = normalizeSearchType(searchType); + + // 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다. + List content = postMapper.findPage(offset, s, blankToNull(tag), blankToNull(keyword), st); + long total = postMapper.countPage(blankToNull(tag), blankToNull(keyword), st); + return PageResponse.of(content, p, s, total); + } + + private String normalizeSearchType(String t) { + return ("content".equals(t) || "comment".equals(t)) ? t : "title"; + } + + /** 상세 조회 (열람 제한 글은 관리자만 전체 열람, 그 외엔 제한 응답) */ + @Transactional + public PostDetail get(Long id, SessionUser viewer) { + Post post = mustFindPost(id); + if (Boolean.TRUE.equals(post.getBlocked()) && !isAdmin(viewer)) { + return PostDetail.restricted(post); // 본문·댓글 숨김, 조회수 증가 없음 + } + if (shouldCountView(id, viewer)) { + postMapper.increaseViewCount(id); + post.setViewCount(post.getViewCount() + 1); + } + return assemble(post); + } + + /** 게시글 열람 제한/해제 (관리자 전용) */ + @Transactional + public void setBlocked(Long id, boolean blocked, String reason, SessionUser user) { + assertAdmin(user); + mustFindPost(id); + postMapper.updateBlocked(id, blocked, blocked ? reason : null); + } + + /** Redis 로 (사용자, 게시글) 단위 첫 열람 여부 판단 → 중복 클릭 시 조회수 증가 방지 */ + private boolean shouldCountView(Long postId, SessionUser viewer) { + if (viewer == null) { + return false; + } + String key = "board:view:" + viewer.getId() + ":" + postId; + Boolean firstView = redisTemplate.opsForValue().setIfAbsent(key, "1", VIEW_DEDUP_TTL); + return Boolean.TRUE.equals(firstView); + } + + @Transactional + public PostDetail create(PostRequest req, SessionUser author) { + Post post = Post.builder() + .title(req.getTitle()) + .content(req.getContent()) + .authorId(author.getId()) + .authorName(author.getName()) + .build(); + postMapper.insert(post); + applyTags(post.getId(), req.getTagIds()); + return assemble(mustFindPost(post.getId())); + } + + @Transactional + public PostDetail update(Long id, PostRequest req, SessionUser user) { + Post post = mustFindPost(id); + if (Boolean.TRUE.equals(post.getBlocked()) && !isAdmin(user)) { + throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글은 수정할 수 없습니다."); + } + assertCanModify(post.getAuthorId(), user); + + post.setTitle(req.getTitle()); + post.setContent(req.getContent()); + postMapper.update(post); + + tagMapper.deletePostTagsByPostId(id); + applyTags(id, req.getTagIds()); + return assemble(mustFindPost(id)); + } + + @Transactional + public void delete(Long id, SessionUser user) { + Post post = mustFindPost(id); + assertCanModify(post.getAuthorId(), user); + tagMapper.deletePostTagsByPostId(id); + commentMapper.deleteByPostId(id); + postMapper.delete(id); + } + + /* ===================== 댓글 ===================== */ + + @Transactional + public CommentResponse addComment(Long postId, CommentRequest req, SessionUser author) { + Post post = mustFindPost(postId); + if (Boolean.TRUE.equals(post.getBlocked())) { + throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글에는 댓글을 작성할 수 없습니다."); + } + Comment comment = Comment.builder() + .postId(postId) + .authorId(author.getId()) + .authorName(author.getName()) + .content(req.getContent()) + .build(); + commentMapper.insert(comment); + return CommentResponse.from(commentMapper.findById(comment.getId())); + } + + @Transactional + public void deleteComment(Long commentId, SessionUser user) { + Comment comment = commentMapper.findById(commentId); + if (comment == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다."); + } + assertCanModify(comment.getAuthorId(), user); + commentMapper.delete(commentId); + } + + /* ===================== 태그 ===================== */ + + public List allTags() { + return tagMapper.findAllNames(); + } + + /* ===================== 내부 ===================== */ + + private PostDetail assemble(Post post) { + List tags = tagMapper.findNamesByPostId(post.getId()); + List comments = commentMapper.findByPostId(post.getId()) + .stream().map(CommentResponse::from).toList(); + return PostDetail.of(post, tags, comments); + } + + /** 선택된 태그 ID 중 실제 DB에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */ + private void applyTags(Long postId, List tagIds) { + if (tagIds == null || tagIds.isEmpty()) { + return; + } + List distinct = tagIds.stream().filter(Objects::nonNull).distinct().toList(); + if (distinct.isEmpty()) { + return; + } + for (Long tagId : tagMapper.findExistingIds(distinct)) { + tagMapper.insertPostTag(postId, tagId); + } + } + + private Post mustFindPost(Long id) { + Post post = postMapper.findById(id); + if (post == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다."); + } + return post; + } + + /** 작성자 본인 또는 관리자만 수정/삭제 가능 */ + private void assertCanModify(Long authorId, SessionUser user) { + if (!authorId.equals(user.getId()) && !isAdmin(user)) { + throw new ApiException(HttpStatus.FORBIDDEN, "권한이 없습니다."); + } + } + + private boolean isAdmin(SessionUser user) { + return user != null && "ADMIN".equals(user.getRole()); + } + + private void assertAdmin(SessionUser user) { + if (!isAdmin(user)) { + throw new ApiException(HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다."); + } + } + + private String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } +} diff --git a/src/main/java/com/sb/web/board/service/TagService.java b/src/main/java/com/sb/web/board/service/TagService.java new file mode 100644 index 0000000..de48043 --- /dev/null +++ b/src/main/java/com/sb/web/board/service/TagService.java @@ -0,0 +1,170 @@ +package com.sb.web.board.service; + +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.TagCategoryMapper; +import com.sb.web.board.mapper.TagMapper; +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.ArrayList; +import java.util.List; + +/** + * 태그 카테고리 + 태그 관리 서비스 (관리자 기능 + 그룹 조회). + */ +@Service +@RequiredArgsConstructor +public class TagService { + + private final TagCategoryMapper categoryMapper; + private final TagMapper tagMapper; + private final BoardSettingMapper boardSettingMapper; + + /** 카테고리별로 묶은 전체 태그 (관리 화면용) */ + public List listGrouped() { + List result = new ArrayList<>(); + for (TagCategory c : categoryMapper.findAll()) { + result.add(toGroup(c)); + } + return result; + } + + /** 글 작성 시 선택 가능한 태그 그룹 — 게시판 설정 카테고리로 제한 (없으면 전체) */ + public List listWritableGroups() { + Long catId = boardSettingMapper.findTagCategoryId(); + if (catId == null) { + return listGrouped(); + } + TagCategory c = categoryMapper.findById(catId); + return c == null ? listGrouped() : List.of(toGroup(c)); + } + + private TagCategoryResponse toGroup(TagCategory c) { + List tags = tagMapper.findByCategoryId(c.getId()) + .stream().map(TagResponse::from).toList(); + return TagCategoryResponse.of(c, tags); + } + + /* ===================== 게시판 설정 ===================== */ + + 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, "카테고리를 찾을 수 없습니다."); + } + boardSettingMapper.updateTagCategoryId(tagCategoryId); + return BoardSettingResponse.builder().tagCategoryId(tagCategoryId).build(); + } + + /* ===================== 카테고리 ===================== */ + + @Transactional + public TagCategoryResponse createCategory(TagCategoryRequest req) { + String name = req.getName().trim(); + if (categoryMapper.countByName(name, null) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 카테고리입니다."); + } + TagCategory c = TagCategory.builder() + .name(name) + .sortOrder(req.getSortOrder() != null ? req.getSortOrder() : 0) + .build(); + categoryMapper.insert(c); + return TagCategoryResponse.of(c, List.of()); + } + + @Transactional + public TagCategoryResponse updateCategory(Long id, TagCategoryRequest req) { + TagCategory c = mustFindCategory(id); + String name = req.getName().trim(); + if (categoryMapper.countByName(name, id) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 카테고리입니다."); + } + c.setName(name); + if (req.getSortOrder() != null) { + c.setSortOrder(req.getSortOrder()); + } + categoryMapper.update(c); + List tags = tagMapper.findByCategoryId(id).stream().map(TagResponse::from).toList(); + return TagCategoryResponse.of(c, tags); + } + + /** 카테고리 삭제 — 소속 태그와 그 연결(post_tag)까지 함께 삭제 */ + @Transactional + public void deleteCategory(Long id) { + mustFindCategory(id); + for (Tag t : tagMapper.findByCategoryId(id)) { + tagMapper.deletePostTagsByTagId(t.getId()); + tagMapper.delete(t.getId()); + } + categoryMapper.delete(id); + } + + /* ===================== 태그 ===================== */ + + @Transactional + public TagResponse createTag(TagRequest req) { + mustFindCategory(req.getCategoryId()); + String name = req.getName().trim(); + if (tagMapper.countByName(name, null) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다."); + } + Tag t = Tag.builder().name(name).categoryId(req.getCategoryId()).build(); + tagMapper.insert(t); + return TagResponse.from(t); + } + + @Transactional + public TagResponse updateTag(Long id, TagRequest req) { + Tag t = mustFindTag(id); + mustFindCategory(req.getCategoryId()); + String name = req.getName().trim(); + if (tagMapper.countByName(name, id) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다."); + } + t.setName(name); + t.setCategoryId(req.getCategoryId()); + tagMapper.update(t); + return TagResponse.from(t); + } + + @Transactional + public void deleteTag(Long id) { + mustFindTag(id); + tagMapper.deletePostTagsByTagId(id); + tagMapper.delete(id); + } + + /* ===================== 내부 ===================== */ + + private TagCategory mustFindCategory(Long id) { + TagCategory c = categoryMapper.findById(id); + if (c == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다."); + } + return c; + } + + private Tag mustFindTag(Long id) { + Tag t = tagMapper.findById(id); + if (t == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "태그를 찾을 수 없습니다."); + } + return t; + } +} diff --git a/src/main/java/com/example/sb_bt/config/CorsConfig.java b/src/main/java/com/sb/web/common/config/CorsConfig.java similarity index 96% rename from src/main/java/com/example/sb_bt/config/CorsConfig.java rename to src/main/java/com/sb/web/common/config/CorsConfig.java index 6a8109a..9d3fa69 100644 --- a/src/main/java/com/example/sb_bt/config/CorsConfig.java +++ b/src/main/java/com/sb/web/common/config/CorsConfig.java @@ -1,4 +1,4 @@ -package com.example.sb_bt.config; +package com.sb.web.common.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; diff --git a/src/main/java/com/sb/web/common/config/PasswordConfig.java b/src/main/java/com/sb/web/common/config/PasswordConfig.java new file mode 100644 index 0000000..9bad04a --- /dev/null +++ b/src/main/java/com/sb/web/common/config/PasswordConfig.java @@ -0,0 +1,19 @@ +package com.sb.web.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * 비밀번호 해싱용 BCrypt 인코더. + * (전체 Spring Security 필터체인은 도입하지 않고 crypto 모듈만 사용) + */ +@Configuration +public class PasswordConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/example/sb_bt/config/RedisConfig.java b/src/main/java/com/sb/web/common/config/RedisConfig.java similarity index 53% rename from src/main/java/com/example/sb_bt/config/RedisConfig.java rename to src/main/java/com/sb/web/common/config/RedisConfig.java index ad6aa7b..9bdcb0f 100644 --- a/src/main/java/com/example/sb_bt/config/RedisConfig.java +++ b/src/main/java/com/sb/web/common/config/RedisConfig.java @@ -1,6 +1,10 @@ -package com.example.sb_bt.config; +package com.sb.web.common.config; +import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -11,7 +15,10 @@ import org.springframework.data.redis.serializer.StringRedisSerializer; /** * RedisTemplate 구성. * - Key : String 직렬화 - * - Value : JSON 직렬화 (객체 저장 가능) + * - Value : JSON 직렬화 (타입 정보 포함 → 저장한 구체 타입 그대로 복원) + * + * 값 직렬화에 @class 타입 정보를 포함해야 역직렬화 시 LinkedHashMap 이 아니라 + * 원래 클래스(SessionUser, User 등)로 복원된다. */ @Configuration public class RedisConfig { @@ -21,9 +28,17 @@ public class RedisConfig { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); // LocalDateTime 등 java.time 지원 + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + mapper.activateDefaultTyping( + BasicPolymorphicTypeValidator.builder().allowIfBaseType(Object.class).build(), + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY); + StringRedisSerializer stringSerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer = - new GenericJackson2JsonRedisSerializer(new ObjectMapper()); + new GenericJackson2JsonRedisSerializer(mapper); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); diff --git a/src/main/java/com/sb/web/common/config/WebConfig.java b/src/main/java/com/sb/web/common/config/WebConfig.java new file mode 100644 index 0000000..5e25459 --- /dev/null +++ b/src/main/java/com/sb/web/common/config/WebConfig.java @@ -0,0 +1,31 @@ +package com.sb.web.common.config; + +import com.sb.web.auth.web.AdminInterceptor; +import com.sb.web.auth.web.AuthInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 인증 인터셉터 등록. + * 보호가 필요한 엔드포인트에만 적용한다. (로그인/회원가입은 비보호) + * 보호 대상이 늘어나면 addPathPatterns 에 추가. + */ +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final AuthInterceptor authInterceptor; + private final AdminInterceptor adminInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + // 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행) + registry.addInterceptor(authInterceptor) + .addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/board/**", "/api/admin/**", "/api/account/**"); + // 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사) + registry.addInterceptor(adminInterceptor) + .addPathPatterns("/api/admin/**"); + } +} diff --git a/src/main/java/com/example/sb_bt/controller/HealthController.java b/src/main/java/com/sb/web/common/controller/HealthController.java similarity index 92% rename from src/main/java/com/example/sb_bt/controller/HealthController.java rename to src/main/java/com/sb/web/common/controller/HealthController.java index f8bd865..0052e89 100644 --- a/src/main/java/com/example/sb_bt/controller/HealthController.java +++ b/src/main/java/com/sb/web/common/controller/HealthController.java @@ -1,4 +1,4 @@ -package com.example.sb_bt.controller; +package com.sb.web.common.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/com/sb/web/common/exception/ApiException.java b/src/main/java/com/sb/web/common/exception/ApiException.java new file mode 100644 index 0000000..9026d0e --- /dev/null +++ b/src/main/java/com/sb/web/common/exception/ApiException.java @@ -0,0 +1,18 @@ +package com.sb.web.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 비즈니스 예외. HTTP 상태와 사용자 메시지를 함께 담는다. + */ +@Getter +public class ApiException extends RuntimeException { + + private final HttpStatus status; + + public ApiException(HttpStatus status, String message) { + super(message); + this.status = status; + } +} diff --git a/src/main/java/com/sb/web/common/exception/GlobalExceptionHandler.java b/src/main/java/com/sb/web/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..b6b9024 --- /dev/null +++ b/src/main/java/com/sb/web/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,48 @@ +package com.sb.web.common.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 공통 예외 처리. 모든 오류를 일관된 JSON 형태로 응답한다. + * { "status": , "message": "<설명>" } + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ApiException.class) + public ResponseEntity> handleApi(ApiException e) { + return ResponseEntity.status(e.getStatus()).body(body(e.getStatus(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(fe -> fe.getDefaultMessage()) + .findFirst() + .orElse("입력값이 올바르지 않습니다."); + return ResponseEntity.badRequest().body(body(HttpStatus.BAD_REQUEST, message)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception e) { + log.error("[unhandled] {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(body(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다.")); + } + + private Map body(HttpStatus status, String message) { + Map m = new LinkedHashMap<>(); + m.put("status", status.value()); + m.put("message", message); + return m; + } +} diff --git a/src/main/java/com/example/sb_bt/controller/UserController.java b/src/main/java/com/sb/web/user/controller/UserController.java similarity index 91% rename from src/main/java/com/example/sb_bt/controller/UserController.java rename to src/main/java/com/sb/web/user/controller/UserController.java index 8967479..98a6089 100644 --- a/src/main/java/com/example/sb_bt/controller/UserController.java +++ b/src/main/java/com/sb/web/user/controller/UserController.java @@ -1,7 +1,7 @@ -package com.example.sb_bt.controller; +package com.sb.web.user.controller; -import com.example.sb_bt.domain.User; -import com.example.sb_bt.service.UserService; +import com.sb.web.user.domain.User; +import com.sb.web.user.service.UserService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/example/sb_bt/domain/User.java b/src/main/java/com/sb/web/user/domain/User.java similarity index 93% rename from src/main/java/com/example/sb_bt/domain/User.java rename to src/main/java/com/sb/web/user/domain/User.java index 963b99e..e17d08f 100644 --- a/src/main/java/com/example/sb_bt/domain/User.java +++ b/src/main/java/com/sb/web/user/domain/User.java @@ -1,4 +1,4 @@ -package com.example.sb_bt.domain; +package com.sb.web.user.domain; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/example/sb_bt/mapper/UserMapper.java b/src/main/java/com/sb/web/user/mapper/UserMapper.java similarity index 84% rename from src/main/java/com/example/sb_bt/mapper/UserMapper.java rename to src/main/java/com/sb/web/user/mapper/UserMapper.java index 55bcd06..7309462 100644 --- a/src/main/java/com/example/sb_bt/mapper/UserMapper.java +++ b/src/main/java/com/sb/web/user/mapper/UserMapper.java @@ -1,6 +1,6 @@ -package com.example.sb_bt.mapper; +package com.sb.web.user.mapper; -import com.example.sb_bt.domain.User; +import com.sb.web.user.domain.User; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; diff --git a/src/main/java/com/example/sb_bt/service/UserService.java b/src/main/java/com/sb/web/user/service/UserService.java similarity index 93% rename from src/main/java/com/example/sb_bt/service/UserService.java rename to src/main/java/com/sb/web/user/service/UserService.java index 6164a3b..7da6668 100644 --- a/src/main/java/com/example/sb_bt/service/UserService.java +++ b/src/main/java/com/sb/web/user/service/UserService.java @@ -1,7 +1,7 @@ -package com.example.sb_bt.service; +package com.sb.web.user.service; -import com.example.sb_bt.domain.User; -import com.example.sb_bt.mapper.UserMapper; +import com.sb.web.user.domain.User; +import com.sb.web.user.mapper.UserMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b8f3372..a9ed0c8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,28 +4,39 @@ server: context-path: / spring: + # 로컬 개발 시 .env(properties 형식, git 미추적)에서 환경변수 로드. 없으면 OS/컨테이너 환경변수 사용. + config: + import: optional:file:.env[.properties] + application: name: sb_bt - # ===== MariaDB ===== + # ===== SQL 초기화 ===== 기동 시 member 테이블 자동 생성(IF NOT EXISTS 라 멱등) + sql: + init: + mode: always + schema-locations: classpath:db/member.sql,classpath:db/board.sql,classpath:db/account.sql,classpath:db/dev-seed.sql + continue-on-error: true + + # ===== MariaDB ===== (접속 정보는 환경변수로 외부화) datasource: driver-class-name: org.mariadb.jdbc.Driver - url: jdbc:mariadb://localhost:3306/sb_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul - username: sb_user - password: sb_pass + url: ${DB_URL:jdbc:mariadb://localhost:3306/sb_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul} + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:} hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 pool-name: sbHikariPool - # ===== Redis ===== + # ===== Redis ===== (접속 정보는 환경변수로 외부화) data: redis: - host: localhost - port: 6379 - password: - database: 0 + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} timeout: 3000ms lettuce: pool: @@ -35,7 +46,7 @@ spring: # ===== MyBatis ===== mybatis: - type-aliases-package: com.example.sb_bt.domain + type-aliases-package: com.sb.web.user.domain,com.sb.web.auth.domain mapper-locations: classpath:mapper/**/*.xml configuration: map-underscore-to-camel-case: true @@ -47,5 +58,6 @@ mybatis: logging: level: root: INFO - com.example.sb_bt: DEBUG - com.example.sb_bt.mapper: DEBUG + com.sb.web: DEBUG + com.sb.web.user.mapper: DEBUG + com.sb.web.auth.mapper: DEBUG diff --git a/src/main/resources/db/account.sql b/src/main/resources/db/account.sql new file mode 100644 index 0000000..ba90636 --- /dev/null +++ b/src/main/resources/db/account.sql @@ -0,0 +1,154 @@ +-- ============================================================ +-- 가계부 스키마 (MariaDB) — account_entry +-- 본인(member)이 등록한 내역만 조회 가능 (소유자 격리) +-- 실행: 애플리케이션 기동 시 연결된 DB(sbdb)에 자동 생성 (IF NOT EXISTS) +-- ============================================================ + +-- 계좌/카드/대출 (사용자별) — BANK(은행) / CARD(카드) / LOAN(대출) +-- 잔액 부호: 자산(+) / 부채(카드·대출, −). opening_balance 는 기준일의 부호있는 시작 잔액. +CREATE TABLE IF NOT EXISTS wallet ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL COMMENT '소유자 member.id', + type VARCHAR(10) NOT NULL COMMENT 'BANK / CARD / LOAN / INVEST', + name VARCHAR(50) NOT NULL COMMENT '별칭', + issuer VARCHAR(50) NULL COMMENT '은행명/카드사/대출기관/증권사', + account_number VARCHAR(50) NULL COMMENT '계좌번호(BANK)', + card_type VARCHAR(10) NULL COMMENT 'CREDIT / CHECK (CARD)', + opening_balance BIGINT NOT NULL DEFAULT 0 COMMENT '초기 잔액(부호있음: 자산+, 부채-)', + opening_date DATE NULL COMMENT '초기잔액 기준일', + current_value BIGINT NULL COMMENT '현재 평가금액(INVEST, 수동 갱신)', + 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_wallet_member (member_id, type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 기존 wallet 컬럼 추가 (멱등) +ALTER TABLE wallet ADD COLUMN IF NOT EXISTS opening_balance BIGINT NOT NULL DEFAULT 0; +ALTER TABLE wallet ADD COLUMN IF NOT EXISTS opening_date DATE NULL; +ALTER TABLE wallet ADD COLUMN IF NOT EXISTS current_value BIGINT NULL; + +CREATE TABLE IF NOT EXISTS account_entry ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL COMMENT '소유자 member.id', + entry_date DATE NOT NULL COMMENT '거래일', + type VARCHAR(10) NOT NULL COMMENT 'INCOME / EXPENSE / TRANSFER', + category VARCHAR(50) NULL COMMENT '분류(식비, 급여 등)', + amount BIGINT NOT NULL COMMENT '금액(원, 양수)', + memo VARCHAR(255) NULL, + wallet_id BIGINT NULL COMMENT '발생/출금 계좌(wallet.id)', + to_wallet_id BIGINT NULL COMMENT '이체 입금 계좌(TRANSFER)', + 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_account_member_date (member_id, entry_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 기존 account_entry 컬럼 추가 (멱등) +ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS wallet_id BIGINT NULL; +ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS to_wallet_id BIGINT NULL; + +-- 가계부 태그 (사용자별 관리 — 게시판 태그와 분리) +CREATE TABLE IF NOT EXISTS account_tag ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL COMMENT '소유자 member.id', + name VARCHAR(50) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_account_tag_member_name (member_id, name), + KEY idx_account_tag_member (member_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 정기/반복 거래 규칙 (사용자별) +CREATE TABLE IF NOT EXISTS recurring ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL COMMENT '소유자 member.id', + title VARCHAR(100) NOT NULL COMMENT '이름(월세, 급여 등)', + type VARCHAR(10) NOT NULL COMMENT 'INCOME/EXPENSE/TRANSFER', + amount BIGINT NOT NULL, + category VARCHAR(50) NULL, + memo VARCHAR(255) NULL, + wallet_id BIGINT NULL COMMENT '발생/출금 계좌', + to_wallet_id BIGINT NULL COMMENT '이체 입금 계좌', + frequency VARCHAR(10) NOT NULL COMMENT 'DAILY/WEEKLY/MONTHLY/YEARLY', + day_of_month INT NULL COMMENT 'MONTHLY/YEARLY (1~31)', + day_of_week INT NULL COMMENT 'WEEKLY (1=월~7=일)', + month_of_year INT NULL COMMENT 'YEARLY (1~12)', + start_date DATE NOT NULL, + end_date DATE NULL, + last_run_date DATE NULL COMMENT '마지막 생성된 거래일', + active TINYINT(1) NOT NULL DEFAULT 1, + 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_recurring_member (member_id, active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 분류(카테고리) — 사용자별, 수입/지출 타입 +CREATE TABLE IF NOT EXISTS account_category ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL COMMENT '소유자 member.id', + type VARCHAR(10) NOT NULL COMMENT 'INCOME / EXPENSE', + name VARCHAR(50) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_category_member_type_name (member_id, type, name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 예산 (사용자별, 카테고리별) +-- fixed=1(고정): base_unit/base_amount 로 일수기준 환산 / fixed=0(비고정): daily/weekly/monthly/yearly 직접 +CREATE TABLE IF NOT EXISTS budget ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL COMMENT '소유자 member.id', + category VARCHAR(50) NOT NULL COMMENT '지출 분류', + fixed TINYINT(1) NOT NULL DEFAULT 0, + base_unit VARCHAR(10) NULL COMMENT 'DAY/WEEK/MONTH (고정)', + base_amount BIGINT NULL COMMENT '고정 기준 금액', + daily BIGINT NULL, + weekly BIGINT NULL, + monthly BIGINT NULL, + yearly BIGINT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_budget_member_category (member_id, category) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 가계부 항목 - 태그 (다대다, tag_id 는 account_tag.id 참조) +CREATE TABLE IF NOT EXISTS account_entry_tag ( + entry_id BIGINT NOT NULL, + tag_id BIGINT NOT NULL, + PRIMARY KEY (entry_id, tag_id), + KEY idx_aet_tag (tag_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 투자 보유 종목 (INVEST 지갑별 · 사용자별) +CREATE TABLE IF NOT EXISTS invest_holding ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL COMMENT '소유자 member.id', + wallet_id BIGINT NOT NULL COMMENT '투자(INVEST) 지갑 wallet.id', + name VARCHAR(100) NOT NULL COMMENT '종목명', + ticker VARCHAR(20) NULL COMMENT '종목코드(선택)', + current_price BIGINT 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_holding_member_wallet (member_id, wallet_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 투자 매매 이력 (종목별 매수/매도) +CREATE TABLE IF NOT EXISTS invest_trade ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL COMMENT '소유자 member.id', + holding_id BIGINT NOT NULL COMMENT 'invest_holding.id', + trade_type VARCHAR(10) NOT NULL COMMENT 'BUY / SELL', + trade_date DATE NOT NULL, + quantity BIGINT NOT NULL COMMENT '수량(주)', + price BIGINT NOT NULL COMMENT '단가(원)', + fee BIGINT NOT NULL DEFAULT 0 COMMENT '수수료/세금(원)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_trade_member_holding (member_id, holding_id), + KEY idx_trade_date (holding_id, trade_date, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/board.sql b/src/main/resources/db/board.sql new file mode 100644 index 0000000..deff0dc --- /dev/null +++ b/src/main/resources/db/board.sql @@ -0,0 +1,79 @@ +-- ============================================================ +-- 자유게시판 스키마 (MariaDB) — post / tag / post_tag / comment +-- 실행: 애플리케이션 기동 시 연결된 DB(sbdb)에 자동 생성됨 (IF NOT EXISTS) +-- ============================================================ + +-- 게시글 +CREATE TABLE IF NOT EXISTS post ( + id BIGINT NOT NULL AUTO_INCREMENT, + title VARCHAR(200) NOT NULL, + content LONGTEXT NOT NULL, + author_id BIGINT NOT NULL COMMENT 'member.id', + author_name VARCHAR(100) NOT NULL COMMENT '표시용(비정규화)', + view_count INT NOT NULL DEFAULT 0, + blocked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '관리자 열람 제한 여부', + block_reason VARCHAR(255) 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_post_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 기존 post 테이블에 컬럼 추가 (멱등) +ALTER TABLE post ADD COLUMN IF NOT EXISTS blocked TINYINT(1) NOT NULL DEFAULT 0; +ALTER TABLE post ADD COLUMN IF NOT EXISTS block_reason VARCHAR(255) NULL; +-- 큰 이미지(base64 인라인) 수용을 위해 content 를 LONGTEXT 로 확대 (멱등) +ALTER TABLE post MODIFY content LONGTEXT NOT NULL; + +-- 태그 카테고리(그룹) — 예: 게시판, 게시판2 ... +CREATE TABLE IF NOT EXISTS tag_category ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_tag_category_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 태그 (카테고리에 소속) +CREATE TABLE IF NOT EXISTS tag ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + category_id BIGINT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_tag_name (name), + KEY idx_tag_category (category_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 기존 tag 테이블에 category_id 가 없으면 추가 (멱등) +ALTER TABLE tag ADD COLUMN IF NOT EXISTS category_id BIGINT NULL; + +-- 게시글-태그 (다대다) +CREATE TABLE IF NOT EXISTS post_tag ( + post_id BIGINT NOT NULL, + tag_id BIGINT NOT NULL, + PRIMARY KEY (post_id, tag_id), + 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); + +-- 댓글 +CREATE TABLE IF NOT EXISTS comment ( + id BIGINT NOT NULL AUTO_INCREMENT, + post_id BIGINT NOT NULL, + author_id BIGINT NOT NULL, + author_name VARCHAR(100) NOT NULL, + content VARCHAR(1000) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_comment_post (post_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/dev-seed.sql b/src/main/resources/db/dev-seed.sql new file mode 100644 index 0000000..412fe4f --- /dev/null +++ b/src/main/resources/db/dev-seed.sql @@ -0,0 +1,11 @@ +-- ============================================================ +-- 개발용 시드 (매 기동 시 실행, 멱등) +-- ⚠️ 운영 배포 시에는 제거하세요. +-- ============================================================ + +-- 테스트 계정 testuser01 을 관리자(ADMIN)로 승격 +UPDATE member SET role = 'ADMIN' WHERE login_id = 'testuser01'; + +-- 레거시(카테고리 미지정) 태그 정리: 새 태그 체계는 모두 카테고리에 소속됨 +DELETE FROM post_tag WHERE tag_id IN (SELECT id FROM (SELECT id FROM tag WHERE category_id IS NULL) x); +DELETE FROM tag WHERE category_id IS NULL; diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql new file mode 100644 index 0000000..a9fa8c1 --- /dev/null +++ b/src/main/resources/db/member.sql @@ -0,0 +1,21 @@ +-- ============================================================ +-- 회원(member) 테이블 (MariaDB) +-- 로컬(아이디/비밀번호) + 소셜(네이버 등) 로그인 확장을 고려한 설계 +-- 실행: 애플리케이션 기동 시 연결된 DB(sbdb)에 자동 생성됨 +-- ============================================================ +CREATE TABLE IF NOT EXISTS member ( + id BIGINT NOT NULL AUTO_INCREMENT, + login_id VARCHAR(50) NULL COMMENT '로컬 로그인 아이디 (소셜 전용 계정은 NULL)', + password VARCHAR(100) NULL COMMENT 'BCrypt 해시 (소셜 전용 계정은 NULL)', + name VARCHAR(100) NOT NULL COMMENT '이름/닉네임', + email VARCHAR(255) NULL, + provider VARCHAR(20) NOT NULL DEFAULT 'LOCAL' COMMENT 'LOCAL / NAVER', + provider_id VARCHAR(100) NULL COMMENT '소셜 제공자 측 고유 사용자 ID', + role VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT 'USER / ADMIN', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE / SUSPENDED / WITHDRAWN', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_member_login_id (login_id), + UNIQUE KEY uk_member_provider (provider, provider_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/mapper/AccountEntryMapper.xml b/src/main/resources/mapper/AccountEntryMapper.xml new file mode 100644 index 0000000..a834c87 --- /dev/null +++ b/src/main/resources/mapper/AccountEntryMapper.xml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + AND YEAR(entry_date) = #{year} + AND MONTH(entry_date) = #{month} + + + + e.id, e.member_id, e.entry_date, e.type, e.category, e.amount, e.memo, + e.wallet_id, w.name AS wallet_name, + e.to_wallet_id, tw.name AS to_wallet_name, + e.created_at, e.updated_at + + + FROM account_entry e + LEFT JOIN wallet w ON w.id = e.wallet_id + LEFT JOIN wallet tw ON tw.id = e.to_wallet_id + + + + + + + + + + + + + + + + + + + + INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo, + wallet_id, to_wallet_id, created_at, updated_at) + VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo}, + #{walletId}, #{toWalletId}, NOW(), NOW()) + + + + UPDATE account_entry + SET entry_date = #{entryDate}, type = #{type}, category = #{category}, + amount = #{amount}, memo = #{memo}, wallet_id = #{walletId}, to_wallet_id = #{toWalletId} + WHERE id = #{id} AND member_id = #{memberId} + + + + DELETE FROM account_entry WHERE id = #{id} AND member_id = #{memberId} + + + + + + UPDATE account_entry SET category = #{newName} + WHERE member_id = #{memberId} AND type = #{type} AND category = #{oldName} + + + + + INSERT INTO account_entry_tag (entry_id, tag_id) VALUES (#{entryId}, #{tagId}) + + + + DELETE FROM account_entry_tag WHERE entry_id = #{entryId} + + + + + diff --git a/src/main/resources/mapper/AccountTagMapper.xml b/src/main/resources/mapper/AccountTagMapper.xml new file mode 100644 index 0000000..b8c6421 --- /dev/null +++ b/src/main/resources/mapper/AccountTagMapper.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + INSERT INTO account_tag (member_id, name, created_at) + VALUES (#{memberId}, #{name}, NOW()) + + + + UPDATE account_tag SET name = #{name} + WHERE id = #{id} AND member_id = #{memberId} + + + + DELETE FROM account_tag WHERE id = #{id} AND member_id = #{memberId} + + + + + + DELETE FROM account_entry_tag WHERE tag_id = #{tagId} + + + diff --git a/src/main/resources/mapper/BoardSettingMapper.xml b/src/main/resources/mapper/BoardSettingMapper.xml new file mode 100644 index 0000000..e201d14 --- /dev/null +++ b/src/main/resources/mapper/BoardSettingMapper.xml @@ -0,0 +1,14 @@ + + + + + + + + UPDATE board_setting SET tag_category_id = #{categoryId} WHERE id = 1 + + + diff --git a/src/main/resources/mapper/BudgetMapper.xml b/src/main/resources/mapper/BudgetMapper.xml new file mode 100644 index 0000000..1a57727 --- /dev/null +++ b/src/main/resources/mapper/BudgetMapper.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + id, member_id, category, fixed, base_unit, base_amount, + daily, weekly, monthly, yearly, created_at, updated_at + + + + + + + + + INSERT INTO budget (member_id, category, fixed, base_unit, base_amount, + daily, weekly, monthly, yearly, created_at, updated_at) + VALUES (#{memberId}, #{category}, #{fixed}, #{baseUnit}, #{baseAmount}, + #{daily}, #{weekly}, #{monthly}, #{yearly}, NOW(), NOW()) + + + + UPDATE budget + SET category = #{category}, fixed = #{fixed}, base_unit = #{baseUnit}, base_amount = #{baseAmount}, + daily = #{daily}, weekly = #{weekly}, monthly = #{monthly}, yearly = #{yearly} + WHERE id = #{id} AND member_id = #{memberId} + + + + DELETE FROM budget WHERE id = #{id} AND member_id = #{memberId} + + + + UPDATE budget SET category = #{newName} + WHERE member_id = #{memberId} AND category = #{oldName} + + + diff --git a/src/main/resources/mapper/CategoryMapper.xml b/src/main/resources/mapper/CategoryMapper.xml new file mode 100644 index 0000000..182ecd1 --- /dev/null +++ b/src/main/resources/mapper/CategoryMapper.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + INSERT INTO account_category (member_id, type, name, sort_order, created_at) + VALUES (#{memberId}, #{type}, #{name}, #{sortOrder}, NOW()) + + + + INSERT IGNORE INTO account_category (member_id, type, name, sort_order, created_at) + VALUES (#{memberId}, #{type}, #{name}, 0, NOW()) + + + + UPDATE account_category SET name = #{name} + WHERE id = #{id} AND member_id = #{memberId} + + + + DELETE FROM account_category WHERE id = #{id} AND member_id = #{memberId} + + + diff --git a/src/main/resources/mapper/CommentMapper.xml b/src/main/resources/mapper/CommentMapper.xml new file mode 100644 index 0000000..db8c319 --- /dev/null +++ b/src/main/resources/mapper/CommentMapper.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + INSERT INTO comment (post_id, author_id, author_name, content, created_at) + VALUES (#{postId}, #{authorId}, #{authorName}, #{content}, NOW()) + + + + DELETE FROM comment WHERE id = #{id} + + + + DELETE FROM comment WHERE post_id = #{postId} + + + diff --git a/src/main/resources/mapper/InvestMapper.xml b/src/main/resources/mapper/InvestMapper.xml new file mode 100644 index 0000000..65ddfa4 --- /dev/null +++ b/src/main/resources/mapper/InvestMapper.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + id, member_id, wallet_id, name, ticker, current_price, created_at, updated_at + id, member_id, holding_id, trade_type, trade_date, quantity, price, fee, created_at + + + + + + + + + + INSERT INTO invest_holding (member_id, wallet_id, name, ticker, current_price, created_at, updated_at) + VALUES (#{memberId}, #{walletId}, #{name}, #{ticker}, #{currentPrice}, NOW(), NOW()) + + + + UPDATE invest_holding + SET name = #{name}, ticker = #{ticker}, current_price = #{currentPrice} + WHERE id = #{id} AND member_id = #{memberId} + + + + DELETE FROM invest_holding WHERE id = #{id} AND member_id = #{memberId} + + + + + + + + + + + INSERT INTO invest_trade (member_id, holding_id, trade_type, trade_date, quantity, price, fee, created_at) + VALUES (#{memberId}, #{holdingId}, #{tradeType}, #{tradeDate}, #{quantity}, #{price}, #{fee}, NOW()) + + + + DELETE FROM invest_trade WHERE id = #{id} AND member_id = #{memberId} + + + + DELETE FROM invest_trade WHERE holding_id = #{holdingId} AND member_id = #{memberId} + + + diff --git a/src/main/resources/mapper/MemberMapper.xml b/src/main/resources/mapper/MemberMapper.xml new file mode 100644 index 0000000..de5fe83 --- /dev/null +++ b/src/main/resources/mapper/MemberMapper.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + id, login_id, password, name, email, provider, provider_id, role, status, created_at, updated_at + + + + + + + + + + + + INSERT INTO member (login_id, password, name, email, provider, provider_id, role, status, created_at, updated_at) + VALUES (#{loginId}, #{password}, #{name}, #{email}, + #{provider}, #{providerId}, #{role}, #{status}, NOW(), NOW()) + + + diff --git a/src/main/resources/mapper/PostMapper.xml b/src/main/resources/mapper/PostMapper.xml new file mode 100644 index 0000000..22d6f73 --- /dev/null +++ b/src/main/resources/mapper/PostMapper.xml @@ -0,0 +1,80 @@ + + + + + + + JOIN post_tag pt ON pt.post_id = p.id + JOIN tag t ON t.id = pt.tag_id + + + + t.name = #{tag} + + + + + AND p.content LIKE CONCAT('%', #{keyword}, '%') + + + AND EXISTS (SELECT 1 FROM comment c + WHERE c.post_id = p.id + AND c.content LIKE CONCAT('%', #{keyword}, '%')) + + + AND p.title LIKE CONCAT('%', #{keyword}, '%') + + + + + + + + + + + + + + INSERT INTO post (title, content, author_id, author_name, view_count, created_at, updated_at) + VALUES (#{title}, #{content}, #{authorId}, #{authorName}, 0, NOW(), NOW()) + + + + UPDATE post + SET title = #{title}, content = #{content} + WHERE id = #{id} + + + + DELETE FROM post WHERE id = #{id} + + + + UPDATE post SET view_count = view_count + 1 WHERE id = #{id} + + + + UPDATE post SET blocked = #{blocked}, block_reason = #{blockReason} WHERE id = #{id} + + + diff --git a/src/main/resources/mapper/RecurringMapper.xml b/src/main/resources/mapper/RecurringMapper.xml new file mode 100644 index 0000000..08b5c7f --- /dev/null +++ b/src/main/resources/mapper/RecurringMapper.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT r.id, r.member_id, r.title, r.type, r.amount, r.category, r.memo, + r.wallet_id, w.name AS wallet_name, r.to_wallet_id, tw.name AS to_wallet_name, + r.frequency, r.day_of_month, r.day_of_week, r.month_of_year, + r.start_date, r.end_date, r.last_run_date, r.active + FROM recurring r + LEFT JOIN wallet w ON w.id = r.wallet_id + LEFT JOIN wallet tw ON tw.id = r.to_wallet_id + + + + + + + + + + INSERT INTO recurring (member_id, title, type, amount, category, memo, wallet_id, to_wallet_id, + frequency, day_of_month, day_of_week, month_of_year, + start_date, end_date, last_run_date, active, created_at, updated_at) + VALUES (#{memberId}, #{title}, #{type}, #{amount}, #{category}, #{memo}, #{walletId}, #{toWalletId}, + #{frequency}, #{dayOfMonth}, #{dayOfWeek}, #{monthOfYear}, + #{startDate}, #{endDate}, #{lastRunDate}, #{active}, NOW(), NOW()) + + + + UPDATE recurring + SET title = #{title}, type = #{type}, amount = #{amount}, category = #{category}, memo = #{memo}, + wallet_id = #{walletId}, to_wallet_id = #{toWalletId}, + frequency = #{frequency}, day_of_month = #{dayOfMonth}, day_of_week = #{dayOfWeek}, + month_of_year = #{monthOfYear}, start_date = #{startDate}, end_date = #{endDate}, active = #{active} + WHERE id = #{id} AND member_id = #{memberId} + + + + DELETE FROM recurring WHERE id = #{id} AND member_id = #{memberId} + + + + UPDATE recurring SET last_run_date = #{lastRunDate} WHERE id = #{id} + + + diff --git a/src/main/resources/mapper/TagCategoryMapper.xml b/src/main/resources/mapper/TagCategoryMapper.xml new file mode 100644 index 0000000..b3e2e24 --- /dev/null +++ b/src/main/resources/mapper/TagCategoryMapper.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + INSERT INTO tag_category (name, sort_order, created_at) + VALUES (#{name}, #{sortOrder}, NOW()) + + + + UPDATE tag_category + SET name = #{name}, sort_order = #{sortOrder} + WHERE id = #{id} + + + + DELETE FROM tag_category WHERE id = #{id} + + + diff --git a/src/main/resources/mapper/TagMapper.xml b/src/main/resources/mapper/TagMapper.xml new file mode 100644 index 0000000..d30e749 --- /dev/null +++ b/src/main/resources/mapper/TagMapper.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + INSERT INTO post_tag (post_id, tag_id) VALUES (#{postId}, #{tagId}) + + + + DELETE FROM post_tag WHERE post_id = #{postId} + + + + + + + + + + + + + + + INSERT INTO tag (name, category_id) VALUES (#{name}, #{categoryId}) + + + + UPDATE tag SET name = #{name}, category_id = #{categoryId} WHERE id = #{id} + + + + DELETE FROM tag WHERE id = #{id} + + + + DELETE FROM post_tag WHERE tag_id = #{tagId} + + + diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml index d4e19c8..bff0b8c 100644 --- a/src/main/resources/mapper/UserMapper.xml +++ b/src/main/resources/mapper/UserMapper.xml @@ -1,7 +1,7 @@ - + diff --git a/src/main/resources/mapper/WalletMapper.xml b/src/main/resources/mapper/WalletMapper.xml new file mode 100644 index 0000000..2f8d4b7 --- /dev/null +++ b/src/main/resources/mapper/WalletMapper.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + id, member_id, type, name, issuer, account_number, card_type, + opening_balance, opening_date, current_value, created_at, updated_at + + + + + + + INSERT INTO wallet (member_id, type, name, issuer, account_number, card_type, + opening_balance, opening_date, current_value, created_at, updated_at) + VALUES (#{memberId}, #{type}, #{name}, #{issuer}, #{accountNumber}, #{cardType}, + #{openingBalance}, #{openingDate}, #{currentValue}, NOW(), NOW()) + + + + UPDATE wallet + SET type = #{type}, name = #{name}, issuer = #{issuer}, + account_number = #{accountNumber}, card_type = #{cardType}, + opening_balance = #{openingBalance}, opening_date = #{openingDate}, + current_value = #{currentValue} + WHERE id = #{id} AND member_id = #{memberId} + + + + + + + DELETE FROM wallet WHERE id = #{id} AND member_id = #{memberId} + + + diff --git a/src/test/java/com/example/sb_bt/SbBtApplicationTests.java b/src/test/java/com/sb/web/SbBtApplicationTests.java similarity index 86% rename from src/test/java/com/example/sb_bt/SbBtApplicationTests.java rename to src/test/java/com/sb/web/SbBtApplicationTests.java index d54e5f5..4b7972f 100644 --- a/src/test/java/com/example/sb_bt/SbBtApplicationTests.java +++ b/src/test/java/com/sb/web/SbBtApplicationTests.java @@ -1,4 +1,4 @@ -package com.example.sb_bt; +package com.sb.web; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest;