Compare commits

..

2 Commits

Author SHA1 Message Date
sb 2f376e6f77 feat(board): 신고 목록 조회 API + 신고 무시(관리자)
Deploy / deploy (push) Failing after 11m22s
- GET /api/board/reports — 글/댓글 신고 대상별 집계(UNION, 신고 많은 순)
- POST /api/board/reports/dismiss — 블라인드 없이 신고 기록만 초기화
- 관리자 신고 관리 화면 백엔드

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:11:58 +09:00
sb 11d1655261 docs: BACKEND.md 현행화(신규 API·스키마·투자 모델·구글로그인)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:11:58 +09:00
6 changed files with 149 additions and 38 deletions
+60 -37
View File
@@ -1,11 +1,11 @@
# Slim Budget — 백엔드 (sb_bt) # 돈돼지 가계부 — 백엔드 (sb_bt)
Spring Boot 기반의 가계부·게시판 REST API 서버. Spring Boot 기반의 가계부·자산·게시판 REST API 서버. (구 Slim Budget)
## 기술 스택 ## 기술 스택
- **Spring Boot 3.5** / **Java 17** - **Spring Boot 3.5** / **Java 17**
- **MyBatis** (XML 매퍼) + **MariaDB** - **MyBatis** (XML 매퍼) + **MariaDB**
- **Redis** (세션 저장) - **Redis** (세션 저장 — DB 이중 저장으로 유실 대비)
- **spring-security-crypto** (BCrypt 비밀번호 해시) - **spring-security-crypto** (BCrypt 비밀번호 해시)
- Lombok, Bean Validation - Lombok, Bean Validation
@@ -13,74 +13,97 @@ Spring Boot 기반의 가계부·게시판 REST API 서버.
``` ```
com.sb.web com.sb.web
├── account # 가계부 (controller / service / mapper / domain / dto) ├── account # 가계부 (controller / service / mapper / domain / dto)
├── auth # 인증 (로그인/세션, AuthInterceptor) │ └ invest·budget·category·recurring·quick·ocr·fx·backup 포함
├── board # 게시판 ├── auth # 인증 (로그인/세션/구글로그인, AuthInterceptor, 포인트)
├── billing # 인앱결제·구독(Google Play Billing 검증, RTDN)
├── board # 게시판 (글/댓글/추천/공지/이미지)
├── user # 사용자 ├── user # 사용자
├── admin # 관리자 (태그 카테고리, 게시판 설정) ├── admin # 관리자 (기본분류·게시판/가입 설정·회원 관리)
└── common # 공통 (예외, 응답, 설정) └── common # 공통 (예외, 응답, 헬스체크, 설정)
``` ```
## 스키마 자동 초기화 ## 스키마 자동 초기화
`spring.sql.init.schema-locations` 로 기동 시 적용 (멱등 — `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`): `spring.sql.init` 로 기동 시 적용. 기본 `mode: never`, 환경변수 **`SQL_INIT_MODE=always`** 로 켜면 실행(멱등 — `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`):
``` ```
classpath:db/member.sql, db/board.sql, db/account.sql, db/dev-seed.sql schema-locations: db/member.sql, db/board.sql, db/account.sql, db/dev-seed.sql
continue-on-error: true
``` ```
주요 테이블: `member`, `wallet`, `account_entry`, `account_tag`, `account_entry_tag`, 주요 테이블:
`account_category`, `budget`, `recurring`, `invest_holding`, `invest_trade`, - **회원/인증**: `member`, `auth_session`(세션 DB 백업), `point_history`, `app_setting`(가입 허용 등), `iap_purchase`(인앱결제)
`post`, `comment`, `tag`, `tag_category` 등. - **가계부**: `wallet`, `account_entry`, `account_category`(`parent_id` 대/소분류), `account_tag`, `account_entry_tag`, `budget`, `budget_income`, `recurring`, `quick_entry`, `invest_holding`, `invest_trade`
- **게시판**: `post`, `comment`, `post_vote`, `comment_vote`, `post_tag`, `tag`, `tag_category`, `tag_category_board`, `board_image`, `report`
- **기타**: `default_category`(기본 분류 템플릿)
## 인증 / 보안 ## 인증 / 보안
- 로그인 시 토큰 발급, 이후 `Authorization: Bearer <token>` 로 인증 - 로그인/구글로그인 시 토큰 발급, 이후 `Authorization: Bearer <token>` 로 인증
- **구글 로그인**: `POST /api/auth/google` 로 ID 토큰 수신 → **tokeninfo 검증(aud=웹 클라이언트 ID)** 후 세션 발급. 검증된 동일 이메일이면 기존 계정에 `member.google_id` 로 연결
- `AuthInterceptor` 가 현재 사용자(`SessionUser`)를 요청 속성(`CURRENT_MEMBER`)으로 주입 - `AuthInterceptor` 가 현재 사용자(`SessionUser`)를 요청 속성(`CURRENT_MEMBER`)으로 주입
- **세션 이중 저장**: Redis 유실·재시작 시 `auth_session`(DB)에서 복원·재수화
- **무차별 대입 방지**: 실패 시도만 카운트 — 로그인 IP당 10회/10분, 비밀번호 재인증 회원당 5회/10분 초과 시 429
- **봇 가입 차단**: 허니팟 + IP 레이트리밋(Redis)
- **모든 가계부 데이터는 `member_id`(소유자)로 격리** — 본인 데이터만 조회/수정 - **모든 가계부 데이터는 `member_id`(소유자)로 격리** — 본인 데이터만 조회/수정
- DB 자격증명 등은 `.env`(gitignore)로 관리, `.env.example` 템플릿 제공 - DB/Redis 자격증명·`ACCOUNT_CRYPTO_KEY`(계좌번호 AES-256-GCM)·`GOOGLE_CLIENT_ID` 등은 `.env`(gitignore), `.env.example` 템플릿 제공
## REST API 개요 ## REST API 개요
### 인증 · 사용자 · 관리자 ### 인증 · 사용자 · 관리자
- `POST /api/auth/login` 외 인증 엔드포인트 - `/api/auth``login`·`signup`·`logout`·`google`·`me`(GET/DELETE) / `password`·`verify-password`·`profile`·`profile-image` / `points`·`point-history` / `signup-enabled`·`google-client-id`
- `/api/users` — 사용자 관리 - `/api/users` — 사용자 CRUD
- `/api/admin/**` — 태그 카테고리·게시판 설정(관리자) - `/api/admin``signup-setting`(가입 허용 토글), `default-categories`(기본 분류), `members`(회원 목록·`role`·`plan`·`status` 변경·삭제), 태그 카테고리
- `/api/board/**` — 게시판(글/댓글/태그/열람제한) - `/api/billing``products`·`subscription`·`google/verify`·`cancel`·`resume`·`restore`·`google/rtdn`(정기 알림)
- `/api/board` — 글/댓글 CRUD, `posts/{id}/vote`(추천), `notice`/`unnotice`(공지), `block`/`unblock`(열람제한), `my/posts`·`my/comments`, `tag-groups`, `recommenders`
- `/api/board/images` (업로드) · `/api/images/{id}` (공개 조회) — 게시판 이미지(DB 저장, 절대 URL 반환)
### 가계부 `/api/account` ### 가계부 `/api/account`
| 메서드·경로 | 설명 | | 메서드·경로 | 설명 |
|---|---| |---|---|
| `GET/POST/PUT/DELETE /entries` | 내역 CRUD. 조회 필터: `year,month,type,category,walletId,keyword,tagId` | | `GET/POST/PUT/DELETE /entries` | 내역 CRUD. 조회 필터: `year,month,type,category,walletId,keyword,tagId` |
| `POST /entries/notification` `POST /entries/parse` | 카드/은행 알림 자동인식 등록 · 자연어 파싱 |
| `GET /entries/pending/count` `POST /entries/{id}/confirm` | 확인 필요(pending) 건수 · 확정 |
| `GET /summary` `GET /stats` | 월 요약 / 기간 버킷(일·주·월·년) 수입·지출 | | `GET /summary` `GET /stats` | 월 요약 / 기간 버킷(일·주·월·년) 수입·지출 |
| `GET /category-stats` | 분류별 합계 (파이차트) | | `GET /category-stats` | 분류별 합계 (파이차트, 대/소분류) |
| `GET/POST/PUT/DELETE /wallets` | 계좌/카드/대출/투자(BANK·CARD·LOAN·INVEST) | | `GET/POST/PUT/DELETE /wallets` (+`/reorder`) | 계좌(BANK·CASH·CARD·LOAN·INVEST) |
| `GET /networth` `GET /networth/trend` | 순자산 / 월별 순자산 추이 | | `GET /networth` `GET /networth/trend` | 순자산 / 순자산 추이(`unit=WEEK&weeks=N` 주간 지원) |
| `GET /wallets/{id}/entries` `POST /repayment` | 계좌별 내역 / 상환·납부(원금=이체, 이자=지출) | | `GET /wallets/{id}/entries` `POST /repayment` | 계좌별 내역 / 상환·납부(원금=이체·이자=지출·연회비=지출) |
| `GET/POST/PUT/DELETE /tags` | 사용자별 가계부 태그 | | `GET/POST/PUT/DELETE /tags` (+`/reorder`) | 사용자별 가계부 태그 |
| `/api/account/categories` (+`/import`) | 분류 CRUD, 기존 내역에서 가져오기 | | `/categories` (+`/reorder`,`/import`) | 분류 CRUD, 순서변경, 기존 내역에서 가져오기 |
| `/api/account/budgets` (+`/status`,`/period`) | 예산 CRUD, 진행률, 기간별 | | `/budgets` (+`/income`,`/copy`,`/status`,`/period`) | 월별 예산 CRUD, 수입, 전월 복사, 진행률, 기간별 |
| `/api/account/recurrings` (+`/run`) | 정기 거래 규칙, 밀린 회차 자동 생성 | | `/recurrings` (+`/run`) | 고정지출 규칙, 밀린 회차 자동 생성 |
| `/api/account/invest/**` | 투자 포트폴리오(아래) | | `/quick-entries` | 자주 쓰는 내역 |
| `/invest/**` | 투자 포트폴리오(아래) |
| `/ocr/receipt` | 영수증 OCR (Google Vision) |
| `POST /restore` | 백업 데이터 복구 (엑셀·JSON) |
> 환율: `GET /api/fx/rate` — 외화 입력 시 환율 자동조회(원화 환산).
### 잔액 · 순자산 모델 ### 잔액 · 순자산 모델
- 부호 모델: 자산(+) / 부채(−) - 부호 모델: 자산(+) / 부채(−). 현금(CASH)은 은행(BANK)과 동일하게 자산 합산
- 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금 (`WalletMapper.findBalances`) - 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금 (`WalletMapper.findBalances`)
- 순자산 = Σ자산 − Σ부채. 투자계좌는 **예수금 + 주식 평가금액**으로 반영 - 순자산 = Σ자산 − Σ부채. 투자계좌는 **예수금 + 주식 평가금액**으로 반영
- 순자산 추이: 현금흐름 누적(이체는 상쇄) + 투자 손익은 현재 시점에 가산 - 순자산 추이: 현금흐름 누적(이체는 상쇄) + 투자 손익은 현재 시점에 가산. 주간(월요일 기준) 집계 지원
### 정기 거래 (recurring) ### 고정지출 (recurring)
- 빈도(DAILY/WEEKLY/MONTHLY/YEARLY) + 일/요일/월 지정 - 빈도(DAILY/WEEKLY/MONTHLY/YEARLY) + 일/요일/월 지정
- `last_run_date` 커서로 **중복 없이** 밀린 회차를 따라잡아 `account_entry` 생성 - `last_run_date` 커서로 **중복 없이** 밀린 회차를 따라잡아 `account_entry` 생성. 자동 생성분은 **'확인 필요'** 상태로 만들어 실제 처리 확인 후 확정
### 투자 포트폴리오 (`/api/account/invest`) — 종목 단위(C) ### 투자 포트폴리오 (`/api/account/invest`) — 종목 단위
| 경로 | 설명 | | 경로 | 설명 |
|---|---| |---|---|
| `GET/POST/PUT/DELETE /holdings` | 보유 종목(종목명·코드·현재가). 집계: 수량·평단·평가금액·평가손익·수익률·실현손익 | | `GET/POST/PUT/DELETE /holdings` | 보유 종목(종목명·코드·현재가). 집계: 수량·평단·평가금액·평가손익·수익률·실현손익 |
| `GET/POST /holdings/{id}/trades`, `DELETE /trades/{id}` | 매수/매도 이력 | | `GET/POST /holdings/{id}/trades`, `PUT/DELETE /trades/{id}` | 매수/매도 이력 (수정 시 시점별 시뮬레이션으로 보유수량 음수 방지) |
- 매매이력(시간순)으로 **이동평균 평단**·실현손익·평가손익 산출 | `POST /holdings/refresh-prices`, `POST /refresh-prices` | 종목코드로 현재가 자동 갱신(네이버 금융) |
-수 시 평단 재계산 `(기존수량×평단 + 매수수량×단가 + 수수료)/총수량`, 매도 시 실현손익 = (매도가 − 평단)×수량 − 수수료 -매이력(시간순)으로 **이동평균 평단**·실현손익·평가손익 산출. 매도수량이 보유수량 초과 시 400
- 매도수량이 보유수량 초과 시 400 반환
- 투자계좌 가치 = 예수금(현금 ± 매매) + 주식평가, 순자산/추이에 연동 - 투자계좌 가치 = 예수금(현금 ± 매매) + 주식평가, 순자산/추이에 연동
- 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외) - 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외)
- ℹ️ 백엔드는 종목 단위 API를 유지하나, **현재 프론트 UI 는 투자금+평가액만 입력하는 단순형**으로 노출
## 개발/실행 ## 개발/실행
```bash ```bash
cp .env.example .env # DB/Redis 자격증명 입력 cp .env.example .env # DB/Redis 자격증명 입력 (SQL_INIT_MODE=always 로 스키마 자동생성)
./gradlew bootRun # http://localhost:8080 ./gradlew bootRun # http://localhost:8080
./gradlew test # 단위 + @WebMvcTest 통합 테스트
``` ```
## 배포
- `main` 푸시 → Gitea Actions 가 CI(빌드·테스트) + Deploy(jar 전송·systemd 재시작) 실행. 테스트 실패 시 배포 중단
- 상세: [deploy/README.md](../deploy/README.md)
@@ -9,6 +9,7 @@ import com.sb.web.board.dto.PostDetail;
import com.sb.web.board.dto.PostRequest; import com.sb.web.board.dto.PostRequest;
import com.sb.web.board.dto.PostSummary; import com.sb.web.board.dto.PostSummary;
import com.sb.web.board.dto.Recommender; import com.sb.web.board.dto.Recommender;
import com.sb.web.board.dto.ReportedItem;
import com.sb.web.board.dto.TagCategoryResponse; import com.sb.web.board.dto.TagCategoryResponse;
import com.sb.web.board.dto.VoteRequest; import com.sb.web.board.dto.VoteRequest;
import com.sb.web.board.dto.VoteResponse; import com.sb.web.board.dto.VoteResponse;
@@ -223,4 +224,20 @@ public class BoardController {
boardService.unblockComment(commentId, current); boardService.unblockComment(commentId, current);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** 신고된 글/댓글 목록 (관리자) — 대상별 신고 수 집계 */
@GetMapping("/reports")
public List<ReportedItem> reports(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return boardService.listReportedItems(current);
}
/** 신고 무시 (관리자) — 블라인드 없이 신고 기록만 초기화 */
@PostMapping("/reports/dismiss")
public ResponseEntity<Void> dismissReport(
@RequestBody Map<String, String> body,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
boardService.dismissReports(body.get("targetType"), Long.valueOf(body.get("targetId")), current);
return ResponseEntity.noContent().build();
}
} }
@@ -0,0 +1,24 @@
package com.sb.web.board.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 신고 누적 항목 (관리자 신고 관리 화면). 대상별(글/댓글)로 신고 수를 집계.
*/
@Data
public class ReportedItem {
private String targetType; // POST / COMMENT
private Long targetId; // 대상(글/댓글) id
private Long postId; // 링크용 글 id (POST=자기 자신, COMMENT=상위 글)
private String category; // 상위 글 게시판(community/saving/tips)
private String title; // 글 제목 (댓글이면 상위 글 제목)
private String content; // 본문/댓글 내용 (앞부분 요약)
private Long authorId; // 작성자 id
private String authorName; // 작성자 표시명
private Boolean blocked; // 이미 블라인드 되었는지
private Integer reportCount; // 누적 신고 수
private LocalDateTime lastReportedAt; // 마지막 신고 시각
}
@@ -1,8 +1,11 @@
package com.sb.web.board.mapper; package com.sb.web.board.mapper;
import com.sb.web.board.dto.ReportedItem;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import java.util.List;
/** /**
* 신고(report) 매퍼 — 글/댓글 신고 누적 집계. * 신고(report) 매퍼 — 글/댓글 신고 누적 집계.
*/ */
@@ -15,6 +18,9 @@ public interface ReportMapper {
@Param("memberId") Long memberId, @Param("memberId") Long memberId,
@Param("reason") String reason); @Param("reason") String reason);
/** 신고된 글/댓글 목록 (대상별 집계 — 관리자 화면) */
List<ReportedItem> listReported();
int countByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId); int countByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
int deleteByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId); int deleteByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
@@ -11,6 +11,7 @@ import com.sb.web.board.dto.PostDetail;
import com.sb.web.board.dto.PostRequest; import com.sb.web.board.dto.PostRequest;
import com.sb.web.board.dto.PostSummary; import com.sb.web.board.dto.PostSummary;
import com.sb.web.board.dto.Recommender; import com.sb.web.board.dto.Recommender;
import com.sb.web.board.dto.ReportedItem;
import com.sb.web.board.dto.VoteResponse; import com.sb.web.board.dto.VoteResponse;
import com.sb.web.board.mapper.CommentMapper; import com.sb.web.board.mapper.CommentMapper;
import com.sb.web.board.mapper.PostMapper; import com.sb.web.board.mapper.PostMapper;
@@ -293,6 +294,23 @@ public class BoardService {
} }
} }
/** 신고된 글/댓글 목록 (관리자 전용) — 대상별 신고 수 집계 */
@Transactional(readOnly = true)
public List<ReportedItem> listReportedItems(SessionUser user) {
assertAdmin(user);
return reportMapper.listReported();
}
/** 신고 무시 (관리자 전용) — 블라인드 없이 신고 기록만 초기화 */
@Transactional
public void dismissReports(String targetType, Long targetId, SessionUser user) {
assertAdmin(user);
if (!T_POST.equals(targetType) && !T_COMMENT.equals(targetType)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "잘못된 신고 대상 유형입니다.");
}
reportMapper.deleteByTarget(targetType, targetId);
}
/* ===================== 추천/비추천 ===================== */ /* ===================== 추천/비추천 ===================== */
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */ /** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
@@ -12,6 +12,29 @@
SELECT COUNT(*) FROM report WHERE target_type = #{targetType} AND target_id = #{targetId} SELECT COUNT(*) FROM report WHERE target_type = #{targetType} AND target_id = #{targetId}
</select> </select>
<!-- 신고된 글/댓글 목록 (대상별 집계). 신고 많은 순 → 최근 신고 순. -->
<select id="listReported" resultType="com.sb.web.board.dto.ReportedItem">
SELECT 'POST' AS targetType, r.target_id AS targetId, p.id AS postId,
p.category AS category, p.title AS title, LEFT(p.content, 120) AS content,
p.author_id AS authorId, p.author_name AS authorName, p.blocked AS blocked,
COUNT(*) AS reportCount, MAX(r.created_at) AS lastReportedAt
FROM report r
JOIN post p ON p.id = r.target_id
WHERE r.target_type = 'POST'
GROUP BY p.id
UNION ALL
SELECT 'COMMENT' AS targetType, r.target_id AS targetId, c.post_id AS postId,
pp.category AS category, pp.title AS title, LEFT(c.content, 120) AS content,
c.author_id AS authorId, c.author_name AS authorName, c.blocked AS blocked,
COUNT(*) AS reportCount, MAX(r.created_at) AS lastReportedAt
FROM report r
JOIN comment c ON c.id = r.target_id
JOIN post pp ON pp.id = c.post_id
WHERE r.target_type = 'COMMENT'
GROUP BY c.id
ORDER BY reportCount DESC, lastReportedAt DESC
</select>
<delete id="deleteByTarget"> <delete id="deleteByTarget">
DELETE FROM report WHERE target_type = #{targetType} AND target_id = #{targetId} DELETE FROM report WHERE target_type = #{targetType} AND target_id = #{targetId}
</delete> </delete>