Compare commits
71 Commits
dev
..
2f376e6f77
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f376e6f77 | |||
| 11d1655261 | |||
| 8ced77480e | |||
| 9ed80f7085 | |||
| 9b04ad3310 | |||
| f62889ac62 | |||
| 511843f1b6 | |||
| 79fc8fea16 | |||
| 190fbe835f | |||
| 3ceb3c4253 | |||
| 186aec7bb4 | |||
| 3535c2e220 | |||
| 204c5ce430 | |||
| 706deb057e | |||
| e5d1b5d40c | |||
| d028d957b6 | |||
| f28ecd853e | |||
| 5fec464f90 | |||
| 483aaa6473 | |||
| 5bd2616de9 | |||
| ad40af18b2 | |||
| 95d84c8138 | |||
| 08338d516f | |||
| dc4e99a6db | |||
| f408715e17 | |||
| 74a2cae9ef | |||
| 6e269888c2 | |||
| 39a11d299c | |||
| f19f653715 | |||
| 16ddf88de3 | |||
| d794549f66 | |||
| 0e26237577 | |||
| 1646ec0a7f | |||
| fe0837cfd6 | |||
| 68f6c985bc | |||
| 47a01356a5 | |||
| 236332637a | |||
| 0e71e7b8cc | |||
| 9d4a415f2e | |||
| 5230ba1587 | |||
| 1f8b968588 | |||
| b90f9ea3b8 | |||
| a61700bdf6 | |||
| 484732c5ed | |||
| e3a24848bc | |||
| fd25968d0e | |||
| c61cec6dad | |||
| 2eef5c3b3e | |||
| 4f8ade5ff3 | |||
| 8bfe596f4e | |||
| 76bbf195df | |||
| b62b51b789 | |||
| 91cc5558e4 | |||
| 27b8829def | |||
| ad2ee4e75d | |||
| eb24a315d3 | |||
| 396e986792 | |||
| 191f1cfbe4 | |||
| 1d4dbc2183 | |||
| 478a75b6d8 | |||
| 41a5780e14 | |||
| 86c56f6c6f | |||
| bd1164866c | |||
| f884cb7b06 | |||
| e5fd31843a | |||
| a821cd8f89 | |||
| cf275fabf6 | |||
| d373e3e9e4 | |||
| f79c2412fd | |||
| f3230dcc5b | |||
| ef7d0f7f83 |
+60
-37
@@ -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)
|
||||||
|
|||||||
@@ -93,4 +93,7 @@ public interface AccountEntryMapper {
|
|||||||
int deleteEntryTagsByEntryId(@Param("entryId") Long entryId);
|
int deleteEntryTagsByEntryId(@Param("entryId") Long entryId);
|
||||||
|
|
||||||
List<String> findTagNamesByEntryId(@Param("entryId") Long entryId);
|
List<String> findTagNamesByEntryId(@Param("entryId") Long entryId);
|
||||||
|
|
||||||
|
/** 복수 항목의 태그를 한 번에 조회 — {entryId, tagName} 맵 반환 */
|
||||||
|
List<Map<String, Object>> findTagsByEntryIds(@Param("entryIds") List<Long> entryIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,21 @@ public class AccountService {
|
|||||||
|
|
||||||
public List<AccountEntryResponse> list(Long memberId, Integer year, Integer month,
|
public List<AccountEntryResponse> list(Long memberId, Integer year, Integer month,
|
||||||
String type, String category, Long walletId, String keyword, Long tagId) {
|
String type, String category, Long walletId, String keyword, Long tagId) {
|
||||||
return mapper.findByMember(memberId, year, month,
|
List<AccountEntry> entries = mapper.findByMember(memberId, year, month,
|
||||||
blankToNull(type), blankToNull(category), walletId, blankToNull(keyword), tagId).stream()
|
blankToNull(type), blankToNull(category), walletId, blankToNull(keyword), tagId);
|
||||||
.map(this::toResponse)
|
if (entries.isEmpty()) return List.of();
|
||||||
|
// 태그 N+1 방지: 한 번에 모든 entry 의 태그를 조회 후 Map 으로 그룹화
|
||||||
|
List<Long> ids = entries.stream().map(AccountEntry::getId).toList();
|
||||||
|
Map<Long, List<String>> tagsByEntry = new HashMap<>();
|
||||||
|
for (Map<String, Object> row : mapper.findTagsByEntryIds(ids)) {
|
||||||
|
Object rawId = row.get("entry_id");
|
||||||
|
String name = (String) row.get("tag_name");
|
||||||
|
if (rawId == null || name == null) continue;
|
||||||
|
Long eid = ((Number) rawId).longValue();
|
||||||
|
tagsByEntry.computeIfAbsent(eid, k -> new ArrayList<>()).add(name);
|
||||||
|
}
|
||||||
|
return entries.stream()
|
||||||
|
.map(e -> AccountEntryResponse.from(e, tagsByEntry.getOrDefault(e.getId(), List.of())))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== 추천/비추천 ===================== */
|
/* ===================== 추천/비추천 ===================== */
|
||||||
|
|
||||||
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
|
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class CorsConfig implements WebMvcConfigurer {
|
|||||||
registry.addMapping("/api/**")
|
registry.addMapping("/api/**")
|
||||||
.allowedOrigins(
|
.allowedOrigins(
|
||||||
"http://localhost:5173", // Vue 개발 서버
|
"http://localhost:5173", // Vue 개발 서버
|
||||||
|
"http://localhost:5174", // Vue 개발 서버 (포트 충돌 시 자동 증가)
|
||||||
"capacitor://localhost", // Capacitor (iOS scheme)
|
"capacitor://localhost", // Capacitor (iOS scheme)
|
||||||
"https://localhost", // Capacitor Android (기본 https scheme)
|
"https://localhost", // Capacitor Android (기본 https scheme)
|
||||||
"http://localhost", // Capacitor Android (http scheme)
|
"http://localhost", // Capacitor Android (http scheme)
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ spring:
|
|||||||
max-request-size: 12MB
|
max-request-size: 12MB
|
||||||
|
|
||||||
# ===== SQL 초기화 ===== 기동 시 member 테이블 자동 생성(IF NOT EXISTS 라 멱등)
|
# ===== SQL 초기화 ===== 기동 시 member 테이블 자동 생성(IF NOT EXISTS 라 멱등)
|
||||||
|
# 로컬: never (원격 DB에 매번 DDL 실행 시 기동 지연). 운영: always (배포서버 환경변수로 덮어씀)
|
||||||
sql:
|
sql:
|
||||||
init:
|
init:
|
||||||
mode: always
|
mode: ${SQL_INIT_MODE:never}
|
||||||
schema-locations: classpath:db/member.sql,classpath:db/board.sql,classpath:db/account.sql,classpath:db/dev-seed.sql
|
schema-locations: classpath:db/member.sql,classpath:db/board.sql,classpath:db/account.sql,classpath:db/dev-seed.sql
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
@@ -81,6 +82,12 @@ app:
|
|||||||
|
|
||||||
# ===== Logging =====
|
# ===== Logging =====
|
||||||
logging:
|
logging:
|
||||||
|
file:
|
||||||
|
name: logs/app.log
|
||||||
|
logback:
|
||||||
|
rollingpolicy:
|
||||||
|
file-name-pattern: logs/app.%d{yyyy-MM-dd}.%i.log
|
||||||
|
max-history: 30
|
||||||
level:
|
level:
|
||||||
root: INFO
|
root: INFO
|
||||||
com.sb.web: DEBUG
|
com.sb.web: DEBUG
|
||||||
|
|||||||
@@ -234,4 +234,16 @@
|
|||||||
ORDER BY t.name
|
ORDER BY t.name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 복수 항목의 태그를 한 번에 조회 — {entry_id, tag_name} 행 반환 -->
|
||||||
|
<select id="findTagsByEntryIds" resultType="map">
|
||||||
|
SELECT aet.entry_id AS entry_id, t.name AS tag_name
|
||||||
|
FROM account_tag t
|
||||||
|
JOIN account_entry_tag aet ON aet.tag_id = t.id
|
||||||
|
WHERE aet.entry_id IN
|
||||||
|
<foreach item="id" collection="entryIds" open="(" separator="," close=")">
|
||||||
|
#{id}
|
||||||
|
</foreach>
|
||||||
|
ORDER BY aet.entry_id, t.name
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user