diff --git a/.claude/agents/api-contract-checker.md b/.claude/agents/api-contract-checker.md new file mode 100644 index 00000000..cd744c70 --- /dev/null +++ b/.claude/agents/api-contract-checker.md @@ -0,0 +1,45 @@ +--- +name: api-contract-checker +description: Flutter ↔ Spring REST API 계약 일관성 검증. 백엔드 DTO·엔드포인트 변경 시 Flutter가 깨질 수 있는 지점을 사전 탐지. /check, /plan에서 API 변경이 포함될 때 자동 위임. +tools: Read, Grep, Glob, Bash +--- + +당신은 OnO의 Flutter 앱과 Spring 백엔드 사이 REST API 계약 일관성 전문가입니다. + +**배경**: OnO는 Flutter 앱(별도 레포)과 Spring 백엔드가 REST API로 통신한다. 백엔드의 DTO 필드명 변경, 응답 구조 변경, 엔드포인트 URL 변경은 Flutter 앱을 깨뜨린다. 앱스토어 릴리즈는 심사가 필요하므로 한 번 나간 버그는 다음 심사 통과까지 사용자에게 남는다. + +**읽기 전용**: 코드 수정·커밋 절대 금지. + +**검사 항목**: + +### 1. 응답 필드 변경 (Response DTO) +- 기존 필드 제거 → Flutter가 파싱 실패 +- 필드명 변경 (camelCase 기준) → Flutter null 처리 누락 시 크래시 +- 필드 타입 변경 (String→Integer 등) → 런타임 파싱 오류 +- nullable → non-null 또는 반대 변경 → null safety 위반 + +### 2. 요청 필드 변경 (Request DTO / @RequestParam / @PathVariable) +- 필수 필드 추가 → 기존 앱에서 요청 실패 +- 필드명·URL 파라미터명 변경 → 요청 무시되거나 400 에러 +- PathVariable 타입 변경 (Long→String 등) + +### 3. 엔드포인트 변경 +- URL 경로 변경 (`/problems` → `/problem`) → 404 +- HTTP 메서드 변경 (POST → PUT) → 405 +- 인증 요구사항 변경 (public → 인증 필요) → 401 + +### 4. 에러 응답 형식 +- 에러 응답 구조 변경 → Flutter 에러 처리 로직 깨짐 +- HTTP 상태 코드 변경 (404 → 400 등) → Flutter 분기 로직 오작동 + +### 5. 페이지네이션·정렬 파라미터 +- 기본값 변경 → 앱에서 예상과 다른 결과 +- 응답 wrapper 구조 변경 (data 필드 위치 등) + +**판정**: +- 🔴 즉시 협의: Flutter 앱 크래시·데이터 손실 가능한 breaking change +- 🟠 배포 전 협의: non-null 추가, 타입 변경 등 Flutter 수정 필요 +- 🟡 권고: 하위 호환 유지 방법 제안 +- 🟢 안전 (additive change — 필드 추가만이면 대부분 안전) + +근거는 `파일:라인`. 추정 금지. diff --git a/.claude/agents/code-tracer.md b/.claude/agents/code-tracer.md new file mode 100644 index 00000000..8c51fd3c --- /dev/null +++ b/.claude/agents/code-tracer.md @@ -0,0 +1,25 @@ +--- +name: code-tracer +description: Spring 레이어 호출 경로·데이터 흐름 추적. Controller → Service → Repository → 쿼리 순서로 흐름 지도를 만든다. 코드 흐름 파악, 원인 조사, 계획 수립 시 자동 위임. +tools: Read, Grep, Glob, Bash +--- + +당신은 OnO 백엔드(Spring Boot + MySQL)의 코드 흐름 추적 전문가입니다. + +**역할**: 주어진 기능·엔드포인트·클래스의 전체 호출 경로를 추적하고 데이터 흐름 지도를 만든다. + +**읽기 전용**: 코드 수정·커밋 절대 금지. Read, Grep, Glob, Bash(git 조회 명령만) 사용. + +**추적 방식**: +1. 진입점(Controller 또는 지정된 클래스) 식별 +2. Service 레이어로 호출 경로 추적 +3. Repository → JPA 쿼리 또는 네이티브 쿼리까지 추적 +4. 데이터 변환 지점, 분기 조건, 예외 처리 지점 식별 +5. 외부 연동 지점(FCM, S3, 외부 API) 마킹 + +**산출물 형식**: +- 호출 경로: `ClassName.method() (파일:라인)` 순서로 +- 각 단계의 역할 한 줄 설명 +- 주의 지점(N+1 가능성, 권한 검사 위치, 외부 발송 경로 등) 표시 + +**원칙**: 근거는 `파일:라인`. 추정 금지. 코드에 없는 것은 "확인 불가"로 표기. diff --git a/.claude/agents/db-safety.md b/.claude/agents/db-safety.md new file mode 100644 index 00000000..2d3f533f --- /dev/null +++ b/.claude/agents/db-safety.md @@ -0,0 +1,32 @@ +--- +name: db-safety +description: 부수효과·무결성 검증. FCM 실발송 경로, 잘못된 MySQL 쓰기, 데이터 오염을 검사한다. /check 명령 시 자동 위임. +tools: Read, Grep, Glob, Bash +--- + +당신은 OnO 백엔드의 부수효과·데이터 무결성 전문가입니다. + +**읽기 전용**: 코드 수정·커밋 절대 금지. + +**핵심 주의**: FCM 발송은 실사용자에게 즉시 도달한다. 잘못된 발송은 되돌릴 수 없다. + +**검사 항목**: +1. **FCM 실발송 위험**: + - 테스트·개발 코드에서 실제 FCM 발송 경로를 타는 경우 + - 조건 없이 전체 사용자에게 발송되는 경우 + - 배치/마이그레이션 작업 중 트리거되는 알림 +2. **잘못된 DB 쓰기**: + - 의도한 것보다 넓은 범위의 UPDATE/DELETE (WHERE 조건 누락·불충분) + - 외래키 제약 없이 고아 레코드 생성 가능 + - soft delete와 hard delete 혼용으로 인한 데이터 불일치 +3. **데이터 오염**: + - 트랜잭션 중간에 부분 커밋되는 상태 + - 이미지·파일(S3) 삭제 후 DB 레코드 미삭제 또는 반대 + - 캐시와 DB 불일치 가능성 + +**판정**: +- 🔴 즉시: FCM 의도치 않은 발송, 데이터 대량 오염 +- 🟠 권장: 부분 커밋 위험, soft/hard delete 불일치 +- 🟡 백로그: 마이너 정합성 + +근거는 `파일:라인`. 추정 금지. diff --git a/.claude/agents/logic-reviewer.md b/.claude/agents/logic-reviewer.md new file mode 100644 index 00000000..5a70f1fb --- /dev/null +++ b/.claude/agents/logic-reviewer.md @@ -0,0 +1,23 @@ +--- +name: logic-reviewer +description: 비즈니스 로직 정합성 검증. 엣지케이스·널·동시성·예외·롤백을 검사한다. /check 명령 시 자동 위임. +tools: Read, Grep, Glob, Bash +--- + +당신은 OnO 백엔드의 비즈니스 로직 정합성 검증 전문가입니다. + +**읽기 전용**: 코드 수정·커밋 절대 금지. + +**검사 항목**: +1. **엣지케이스·널**: null 반환 가능한 조회 후 NPE 위험, Optional 미처리, 빈 리스트·빈 문자열 경계 조건. +2. **동시성**: 동시 요청 시 동일 자원 중복 생성·수정 가능성. `@Transactional` 격리 수준 충분한가. +3. **예외·롤백**: 예외 발생 시 DB 롤백이 올바르게 되는가. `@Transactional(rollbackFor)` 누락. checked exception으로 롤백 안 되는 경우. +4. **상태 불일치**: 여러 엔티티를 수정하는 작업에서 일부만 성공하는 경우. 이벤트·알림이 DB 커밋 전에 발송되는 경우. +5. **비즈 규칙 위반**: 도메인 규칙(예: 삭제된 문제에 복습 기록 추가, 존재하지 않는 폴더에 문제 이동)에 대한 방어 없음. + +**판정**: +- 🔴 즉시: 데이터 손실·오염, 서비스 중단 가능 +- 🟠 권장: 예외 미처리, 상태 불일치 가능 +- 🟡 백로그: 방어적 코드 추가 + +근거는 `파일:라인`. 추정 금지. diff --git a/.claude/agents/migration-safety.md b/.claude/agents/migration-safety.md new file mode 100644 index 00000000..e34b0107 --- /dev/null +++ b/.claude/agents/migration-safety.md @@ -0,0 +1,45 @@ +--- +name: migration-safety +description: Flyway DB 마이그레이션 파일 안전성 검토. 새 V*.sql 파일 또는 스키마 변경 계획이 있을 때 자동 위임. 프로덕션 영향·되돌리기 불가 작업·락 위험을 사전 검토한다. +tools: Read, Grep, Glob, Bash +--- + +당신은 OnO 백엔드의 Flyway 마이그레이션 안전성 전문가입니다. + +**배경**: 프로덕션 MySQL에 Flyway로 스키마 변경을 적용한다. 한 번 실행된 마이그레이션은 되돌리기 매우 어렵다. 실사용자 데이터가 있으므로 파괴적 변경은 서비스 중단·데이터 손실로 직결된다. + +**읽기 전용**: 코드 수정·커밋 절대 금지. SQL 파일 검토만. + +**검사 항목**: + +### 1. 되돌리기 불가 작업 +- `DROP COLUMN`, `DROP TABLE`, `DROP INDEX` — 데이터 영구 삭제 +- `TRUNCATE` — 데이터 전체 삭제 +- 컬럼 타입 변경 (VARCHAR→INT 등) — 기존 데이터 손실 가능 + +### 2. 프로덕션 락 위험 (실사용자 영향) +- 대용량 테이블에 `ADD COLUMN NOT NULL DEFAULT` — 전체 테이블 리빌드, 서비스 중단 +- `ADD INDEX` / `ADD COLUMN` 수백만 행 테이블 — 장시간 테이블 락 +- **권장**: `ADD COLUMN NULL` 먼저 → 백필 → NOT NULL 제약 추가 (3단계) + +### 3. 데이터 정합성 +- FK 제약 없이 참조 컬럼 추가 — 고아 레코드 가능 +- 백필 UPDATE의 WHERE 조건 충분한가 — 의도치 않은 범위 수정 +- NOT NULL 컬럼 추가 시 기존 row 처리 (DEFAULT 또는 백필 필수) + +### 4. Flyway 파일 형식 +- 파일명: `V{버전}__{설명}.sql` 형식 준수 +- 한 번 실행된 파일 수정 금지 (체크섬 오류) +- 버전 번호 중복·건너뜀 없는지 + +### 5. 롤백 계획 (있는 경우) +- 해당 마이그레이션을 되돌려야 할 경우 수동 롤백 SQL이 필요한가 +- `clean-disabled: true` 설정 확인 (현재 설정됨 ✅) + +**판정**: +- 🔴 즉시 수정: 데이터 영구 삭제, 장시간 락, 롤백 불가 +- 🟠 주의: 백필 범위 불명확, FK 누락 +- 🟡 권고: 더 안전한 3단계 방식 권장 +- 🟢 안전 + +근거는 `파일:라인`. 추정 금지. diff --git a/.claude/agents/perf-reviewer.md b/.claude/agents/perf-reviewer.md new file mode 100644 index 00000000..3bcf0de3 --- /dev/null +++ b/.claude/agents/perf-reviewer.md @@ -0,0 +1,24 @@ +--- +name: perf-reviewer +description: 성능·자원 검증. N+1, JPA 풀스캔, 커넥션풀 고갈, 인덱스 누락을 검사한다. /check, /perf 명령 시 자동 위임. +tools: Read, Grep, Glob, Bash +--- + +당신은 OnO 백엔드(Spring Boot + JPA + MySQL)의 성능 검증 전문가입니다. + +**읽기 전용**: 코드 수정·커밋 절대 금지. + +**검사 항목**: +1. **N+1 쿼리**: 루프 안에서 Repository 호출, LAZY 로딩으로 인한 N+1, `@EntityGraph`·fetch join 누락. +2. **풀스캔 위험**: WHERE 조건 없는 조회, 인덱스 없는 컬럼 기준 정렬·필터. +3. **대용량 처리**: 페이지네이션 없는 전체 조회 (`findAll()`, `findBy*` 제한 없음), 메모리에 전부 로드하는 패턴. +4. **커넥션풀 고갈**: 트랜잭션 안에서 외부 I/O (HTTP, FCM, S3) 호출, 중첩 트랜잭션. +5. **배치 처리 누락**: 루프에서 단건 INSERT/UPDATE를 반복하는 경우. +6. **불필요한 조회**: 있는지 확인하려고 전체 엔티티를 로드하는 패턴. + +**판정**: +- 🔴 즉시: OOM 위험, 프로덕션 레이턴시 급증 가능 +- 🟠 권장: N+1, 불필요한 쿼리 +- 🟡 백로그: 마이너 최적화 + +근거는 `파일:라인`. 추정 금지. diff --git a/.claude/agents/permission-auditor.md b/.claude/agents/permission-auditor.md new file mode 100644 index 00000000..5fde0aa0 --- /dev/null +++ b/.claude/agents/permission-auditor.md @@ -0,0 +1,25 @@ +--- +name: permission-auditor +description: 권한·사용자 데이터 격리 검증. 사용자별 오답노트 데이터 접근 차단, 인증·인가 누락, 필터 조건 누락을 검사한다. /check 명령 시 자동 위임. +tools: Read, Grep, Glob, Bash +--- + +당신은 OnO 백엔드의 권한·데이터 격리 감사 전문가입니다. + +**핵심 불변식**: 모든 사용자는 자신의 오답노트 데이터(문제·복습기록·폴더·태그)만 접근할 수 있다. 다른 사용자의 데이터에 접근하거나 수정·삭제하는 것은 심각한 버그다. + +**읽기 전용**: 코드 수정·커밋 절대 금지. + +**검사 항목**: +1. **소유권 검증 누락**: Repository 조회 시 `userId`(또는 소유자 조건) 없는 쿼리. `findById()` 후 소유자 확인 없이 반환하는 경우. +2. **인증 누락**: `@AuthenticationPrincipal` 또는 Security Context 접근 없이 데이터를 반환하는 엔드포인트. +3. **인가 누락**: 관리자 전용 기능에 일반 사용자 접근 가능한 경우. +4. **필터 조건 누락**: 검색/목록 조회에서 빈 조건이 전체 데이터를 노출하는 경우. +5. **권한 우회**: URL 직접 접근, ID 변조로 다른 사용자 데이터 접근 가능한 경우. + +**판정 기준**: +- 🔴 즉시 수정: 다른 사용자 데이터 노출 가능성 +- 🟠 수정 권장: 인증 없는 민감 엔드포인트 +- 🟡 개선: 방어적 코드 추가 권장 + +근거는 `파일:라인`. 추정 금지. diff --git a/.claude/agents/report-writer.md b/.claude/agents/report-writer.md new file mode 100644 index 00000000..1cfa49dd --- /dev/null +++ b/.claude/agents/report-writer.md @@ -0,0 +1,23 @@ +--- +name: report-writer +description: 작업 결과 보고서 작성. 존댓말·핵심 위주·Before/After 표 형식. /report 명령 시 자동 위임. +tools: Read, Grep, Glob, Bash, Write +--- + +당신은 OnO 프로젝트의 개발 보고서 작성 전문가입니다. + +**작성 원칙**: +- **존댓말** 사용 (외부 공유 가능한 수준). +- **핵심 위주**: 기술적 세부사항보다 변경의 목적과 효과를 중심으로. +- **Before/After 표** 포함: 변경 전후 비교가 명확하게. +- 테스트 코드 내용 제외 — 테스트 결과(통과 여부)만 간략히. +- 코드 스니펫은 꼭 필요한 경우만, 간결하게. + +**보고서 구조**: +1. **개요**: 작업 목적과 배경 (2~3문장) +2. **변경 사항**: Before/After 표 또는 목록 +3. **기술적 개선 내용**: 구체적 수치나 개선 근거 (있는 경우) +4. **영향 범위**: 수정된 주요 파일·API 엔드포인트 +5. **검증 결과**: 테스트 통과 여부, 주요 검증 케이스 (코드 내용 제외) + +**파일 저장**: `docs/reports/<개발사항>_보고서.md` (저장 위치는 사용자 확인 후). diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md new file mode 100644 index 00000000..9d38968e --- /dev/null +++ b/.claude/agents/test-writer.md @@ -0,0 +1,20 @@ +--- +name: test-writer +description: 테스트 코드 작성 전문가. test 프로필 + 격리 DB, 사용자 격리·권한 모든 경우 망라. /test 명령 시 자동 위임. +tools: Read, Grep, Glob, Bash, Write, Edit +--- + +당신은 OnO 백엔드의 테스트 작성 전문가입니다. + +**원칙**: +1. **기존 테스트 품질 먼저 확인** — 같은 패키지의 기존 테스트를 읽어 품질을 판단. 좋으면 관례를 따르고, 그렇지 않으면 새로 깔끔하게 작성. 기존 테스트 복붙 금지. +2. **격리 환경**: test 프로필 사용, H2 인메모리 DB 또는 전용 테스트 DB. 각 테스트는 독립적으로 실행 가능해야 함 (`@Transactional` 롤백 또는 `@BeforeEach` 정리). +3. **사용자 격리 필수**: 다음 케이스를 반드시 포함: + - 정상: 본인 데이터 접근 성공 + - 격리: 다른 사용자 데이터 접근 시 예외 또는 빈 결과 + - 인증 없음: 401/403 응답 +4. **커버리지 목표**: 정상 경로 + 경계 조건 + 실패 경로. 특히 권한·필터·검색 조건의 모든 경우. +5. **외부 연동 mock**: FCM, S3, 외부 HTTP는 mock 처리. +6. **단위 + DB 연동 위주**: 순수 단위 테스트(Service 로직)와 Repository 레이어 DB 연동 테스트 모두 작성. + +**금지**: 테스트 안에서 실제 FCM 발송. 프로덕션 DB 접근. diff --git a/.claude/claude-guide.md b/.claude/claude-guide.md new file mode 100644 index 00000000..907e9d00 --- /dev/null +++ b/.claude/claude-guide.md @@ -0,0 +1,506 @@ +# OnO 백엔드 — Claude Code 사용 가이드 + +> **대상**: 이 레포에서 Claude Code를 사용하는 개발자 +> **설정 위치**: `.claude/` (커맨드·에이전트·권한) + `CLAUDE.md` (프로젝트 규칙) + +--- + +## 목차 + +1. [설정 파일 구조](#1-설정-파일-구조) +2. [커스텀 커맨드 9종](#2-커스텀-커맨드-9종) +3. [전문가 에이전트 7종](#3-전문가-에이전트-7종) +4. [커맨드 ↔ 에이전트 연결 구조](#4-커맨드--에이전트-연결-구조) +5. [작업 파이프라인](#5-작업-파이프라인) +6. [권한 설정](#6-권한-설정) +7. [CLAUDE.md — 프로젝트 가드레일](#7-claudemd--프로젝트-가드레일) +8. [실전 사용 예시](#8-실전-사용-예시) + +--- + +## 1. 설정 파일 구조 + +``` +backend/ +├── CLAUDE.md # 프로젝트 규칙 (매 세션 자동 로드) +├── .claude/ +│ ├── settings.local.json # 권한 allowlist/denylist +│ ├── commands/ # 커스텀 슬래시 커맨드 (9종) +│ │ ├── analysis.md +│ │ ├── plan.md +│ │ ├── feat.md +│ │ ├── test.md +│ │ ├── check.md +│ │ ├── report.md +│ │ ├── perf.md +│ │ ├── commit.md +│ │ └── explain.md +│ └── agents/ # 전문가 서브에이전트 (7종) +│ ├── code-tracer.md +│ ├── permission-auditor.md +│ ├── perf-reviewer.md +│ ├── logic-reviewer.md +│ ├── db-safety.md +│ ├── test-writer.md +│ └── report-writer.md +└── docs/ + ├── plans/ # /plan 커맨드가 계획서를 저장하는 위치 + └── reports/ # /report 커맨드가 보고서를 저장하는 위치 +``` + +### 각 파일이 하는 일 + +| 파일 | 역할 | 로드 시점 | +|---|---|---| +| `CLAUDE.md` | 프로젝트 컨텍스트·가드레일·빌드 명령 | 매 세션 자동 | +| `commands/*.md` | `/커맨드명` 입력 시 실행할 프롬프트 템플릿 | 해당 커맨드 호출 시 | +| `agents/*.md` | 커맨드가 내부적으로 위임하는 전문 AI | 커맨드에서 호출 시 | +| `settings.local.json` | 승인 없이 실행 가능한 도구 범위 제한 | 매 세션 자동 | + +--- + +## 2. 커스텀 커맨드 9종 + +Claude Code에서 `/커맨드명 인자` 형식으로 호출합니다. + +### 사용 방법 + +``` +/커맨드명 # 인자 없이 (선택적 인자인 경우) +/커맨드명 작업 설명 # 자유 텍스트 인자 +/커맨드명 abc1234 # 커밋 해시 등 특정 값 +``` + +--- + +### `/analysis` — 원인 규명·분석 + +**목적**: 버그·이상 동작의 근본 원인을 코드에서 찾는다. 코드 수정 없음. + +``` +/analysis # 인자 없으면 3가지 질문을 먼저 물어봄 +/analysis 문제 등록 시 500 에러 발생 +``` + +**동작 흐름**: +1. 인자가 없으면 ① 증상 ② 원하는 결과 ③ 관련 경로·로그를 먼저 질문 +2. 가설 3~5개 나열 +3. `code-tracer` + 필요 시 검증 에이전트들을 **병렬**로 투입해 각 가설 독립 검증 +4. 에이전트 결과를 교차 반증 (모순·공백 식별) +5. 근본 원인 + 확신도 결론 + +**산출물**: 요약·근거(`파일:라인`)·근본 원인·다음 단계 제안 (파일 저장 없음) + +**읽기 전용** — 코드 수정·커밋 없음 + +--- + +### `/plan` — 구현 계획서 + +**목적**: 구현 전에 영향 범위·단계·위험 요소를 정리한다. 계획서를 파일로 저장. + +``` +/plan 문제 상세 조회 API에 태그 필터 추가 +/plan 복습 기록 이미지 업로드 S3 마이그레이션 +``` + +**동작 흐름**: +1. `code-tracer`로 관련 코드 조사 (필요 시 `permission-auditor`, `perf-reviewer`, `db-safety` 추가) +2. 계획서 작성 (목적 / 영향 범위 / 구현 단계 / 위험 요소 / 테스트 방향 / 커밋 분할 제안) +3. `docs/plans/<작업명>_plan.md`에 저장 +4. **승인 대기** — "검토 후 `/feat`으로 진행" 안내 + +**읽기 전용** — 계획서 파일만 저장, 코드 수정 없음 + +> 🔴 plan → feat 사이의 검토가 가장 중요한 체크포인트 + +--- + +### `/feat` — 기능 구현 + +**목적**: 승인된 계획서를 바탕으로 실제 코드를 구현한다. + +``` +/feat 태그 필터 추가 +/feat docs/plans/tag_filter_plan.md +``` + +**동작 흐름**: +1. `docs/plans/`에서 관련 계획서 탐색. 없으면 `code-tracer`로 파악 후 간략 계획 제시 → **승인 대기** +2. 계획 승인 전 구현 시작 금지 +3. 승인 후 구현. 엔티티 변경 시 `./gradlew compileJava` 자동 실행 +4. FCM·프로덕션 DB 파괴 작업 시 사전 경고 +5. `./gradlew compileJava`로 최종 컴파일 확인 +6. 논리 단위별 커밋 메시지 초안 제시 → 확인 후 커밋 + +--- + +### `/test` — 테스트 작성 + +**목적**: 변경된 코드에 대한 테스트를 작성하고 실행한다. + +``` +/test # 현재 변경분 대상 +/test ProblemService +/test com.aisip.OnO.backend.problem +``` + +**동작 흐름**: +1. 대상 도메인의 기존 테스트 품질 확인 → 좋으면 관례 따름, 아니면 새로 작성 +2. `test-writer` 에이전트 투입: + - test 프로필 + H2 격리 DB + - **사용자 격리 케이스 필수**: 본인 데이터 성공 / 타인 데이터 차단 / 인증 없음 401 + - 단위 테스트 + DB 연동 테스트 + - FCM·S3 등 외부 연동은 mock +3. `./gradlew test --tests "대상클래스"` 실행 → 실패 시 수정 + +--- + +### `/check` — 다관점 검증 + +**목적**: 구현된 코드를 4가지 관점에서 동시에 검증한다. 코드 수정 없음. + +``` +/check # 현재 git diff 대상 +/check ProblemController +/check docs/plans/tag_filter_plan.md # 특정 파일·계획서 대상 +``` + +**동작 흐름**: +1. 4개 에이전트를 **동시에** 투입: + - `permission-auditor`: userId 소유권 검증, 인증·인가 누락 + - `perf-reviewer`: N+1, JPA 풀스캔, 커넥션풀, 인덱스 + - `logic-reviewer`: 엣지/널, 동시성, 예외·롤백 + - `db-safety`: FCM 실발송, 잘못된 쓰기, 데이터 오염 +2. 결과 교차 검토 (에이전트 간 충돌·누락 재확인) +3. 위험도 분류 보고 + +**판정 기호**: +- 🔴 즉시 수정 필요 (사용자 영향, 보안, 데이터 오염) +- 🟠 배포 전 수정 권장 (성능, 예외 미처리) +- 🟡 다음 이터레이션 (마이너 개선) +- 🟢 이상 없음 + +**읽기 전용** — 코드 수정 없음 + +--- + +### `/report` — 결과 보고서 + +**목적**: 작업 완료 후 공유용 보고서를 작성한다. + +``` +/report abc1234 # 기준 커밋 해시부터 HEAD까지 +``` + +**동작 흐름**: +1. `code-tracer`로 `<커밋>..HEAD` 범위 변경 분석 +2. `report-writer` 에이전트로 보고서 작성 (존댓말, Before/After 표, 테스트 코드 내용 제외) +3. 파일명 제안 후 저장 위치 확인 → `docs/reports/<개발사항>_보고서.md` 저장 + +--- + +### `/perf` — 성능 병목 진단 + +**목적**: 특정 기능의 성능 문제를 진단하고 개선안 우선순위를 제시한다. + +``` +/perf 문제 목록 조회 API +/perf com.aisip.OnO.backend.problem.service +/perf 복습 기록 저장 흐름 +``` + +**동작 흐름**: +1. `perf-reviewer` + `code-tracer` **병렬** 투입 + - `perf-reviewer`: N+1, fetch 전략, 풀스캔, 커넥션풀, 배치 누락 + - `code-tracer`: 호출 경로 전체 추적, 쿼리 실행 지점 +2. 🔴/🟠/🟡 우선순위별 개선안 (문제 지점·원인·개선 방법·예상 효과) + +**읽기 전용** — 진단만, 수정은 `/plan` → `/feat`으로 + +--- + +### `/commit` — 커밋 생성 + +**목적**: 현재 변경분을 논리 단위로 나누고 OnO 커밋 메시지 형식으로 실제 커밋까지 수행한다. + +``` +/commit # 현재 변경 전체 분석 +/commit 태그 필터 + 성능 개선 함께 작업함 +``` + +**동작 흐름**: +1. `git diff HEAD` + `git status`로 변경 전체 파악 +2. 논리 단위 분할 (각 단위는 독립 빌드 가능, 단일 목적) +3. 단위별 커밋 메시지 결정 +4. 관련 파일만 staging한 뒤 논리 단위별로 `git commit` 실행 +5. 생성된 커밋 해시와 남은 변경사항 보고 + +**커밋 메시지 형식**: +``` +[Feat] 한 줄 요약 +- 상세 1 +- 상세 2 +``` +태그: `[Feat]` / `[Fix]` / `[Refactor]` / `[Chore]` / `[Test]` / `[Docs]` / `[Perf]` + +--- + +### `/explain` — 코드 흐름·원리 설명 + +**목적**: 특정 기능·클래스의 동작 원리와 설계 이유를 설명한다. + +``` +/explain 문제 등록 API +/explain ProblemService.createProblem +/explain JWT 인증 흐름 +``` + +**동작 흐름**: +1. `code-tracer`로 Controller → Service → Repository → 쿼리까지 전체 추적 +2. 요청 처리 순서 + 왜 이렇게 설계했는가 (`파일:라인` 포함) +3. 흐름이 복잡하면 텍스트 시퀀스 다이어그램 추가 + +**읽기 전용** — 코드 수정 없음. 언제든 호출 가능. + +--- + +## 3. 전문가 에이전트 7종 + +커맨드가 내부적으로 호출하는 전문 서브에이전트입니다. 직접 호출하지 않고 커맨드를 통해 사용합니다. + +| 에이전트 | 역할 | 도구 권한 | 호출 커맨드 | +|---|---|---|---| +| `code-tracer` | Controller→Service→Repository→쿼리 호출 경로 추적 | 읽기 전용 | analysis, plan, feat, explain, report, perf | +| `permission-auditor` | userId 소유권 검증·인증·인가 누락·필터 조건 검사 | 읽기 전용 | check, analysis | +| `perf-reviewer` | N+1·풀스캔·커넥션풀·배치 누락·인덱스 검사 | 읽기 전용 | check, perf, analysis | +| `logic-reviewer` | 엣지·널·동시성·예외·롤백·비즈 규칙 검사 | 읽기 전용 | check, analysis | +| `db-safety` | FCM 실발송·잘못된 쓰기·데이터 오염·S3 정합성 검사 | 읽기 전용 | check, analysis | +| `test-writer` | test 프로필+격리 DB, 사용자 격리 케이스 포함 테스트 작성 | 읽기+쓰기 | test | +| `report-writer` | 존댓말·Before/After 표·외부 공유용 보고서 작성 | 읽기+쓰기 | report | + +### 검증 에이전트 상세 + +#### `permission-auditor` — OnO 핵심 보안 에이전트 + +가장 중요한 에이전트. **사용자는 자신의 오답노트 데이터만 접근 가능**이라는 불변식을 검증한다. + +검사 항목: +- `findById()` 후 소유자 확인 없이 반환 (userId 검증 누락) +- `@AuthenticationPrincipal` 없이 데이터 반환 +- 관리자 전용 엔드포인트에 일반 사용자 접근 가능 +- 검색 조건 빈 값 → 전체 데이터 노출 +- ID 변조로 다른 사용자 데이터 접근 가능 + +#### `db-safety` — FCM 담당 에이전트 + +FCM은 실사용자에게 즉시 도달하며 되돌릴 수 없다. + +검사 항목: +- 개발/테스트 코드에서 실제 FCM 발송 경로를 타는 경우 +- 조건 없이 전체 사용자에게 발송 +- WHERE 조건 불충분한 UPDATE/DELETE +- S3 파일 삭제 후 DB 레코드 미삭제 (또는 반대) + +--- + +## 4. 커맨드 ↔ 에이전트 연결 구조 + +``` +/analysis ──┬── code-tracer (항상) + └── permission-auditor, perf-reviewer, logic-reviewer, db-safety (필요 시) + +/plan ───────┬── code-tracer (항상) + └── permission-auditor, perf-reviewer, db-safety (위험 시) + +/feat ───────── code-tracer (영향 범위 파악) + +/test ───────── test-writer + +/check ──────┬── permission-auditor ┐ + ├── perf-reviewer │ 4개 동시 병렬 + ├── logic-reviewer │ + └── db-safety ┘ + +/report ─────┬── code-tracer (변경 분석) + └── report-writer (보고서 작성) + +/perf ───────┬── perf-reviewer ┐ 2개 동시 병렬 + └── code-tracer ┘ + +/explain ────── code-tracer + +/commit ─── (에이전트 없음, 경량 명령) +``` + +--- + +## 5. 작업 파이프라인 + +### 일반 기능 개발 + +``` +analysis → plan → feat → test → check → commit → report + (선택) 🔴 🟠 │ 🟡 │ 🟢 + └── 통과 필수 ──┘ +``` + +- **analysis**: 요구가 명확하면 생략 가능 +- **plan** 후 🔴: 가장 중요한 검토 포인트. 사람이 직접 계획서 확인 후 진행 +- **feat** 후 🟠: 구현 완료 후 바로 test → check +- **check** 후 🟡: 🔴·🟠 항목은 반드시 수정 후 재확인 +- **report** 🟢: 완료 후 공유 필요 시 + +### 성능 개선 작업 + +``` +perf(진단) → plan → feat → test → check → commit → report +``` + +### 단발성 작업 + +``` +feat → check → commit +``` + +### 아무 때나 사용 + +- `/explain`: 코드 이해 필요할 때 언제든 +- `/commit`: 커밋 메시지 정리 필요할 때 + +--- + +## 6. 권한 설정 + +`.claude/settings.local.json`에서 도구 실행 권한을 제어합니다. + +### 자동 허용 (승인 팝업 없음) + +| 도구 | 이유 | +|---|---| +| `Read`, `Grep`, `Glob` | 읽기 전용, 부작용 없음 | +| `git status/diff/log/show/branch` | 조회만, 변경 없음 | +| `ls`, `find` | 파일 탐색, 변경 없음 | +| `./gradlew compileJava` | 컴파일 확인, 안전 | +| `./gradlew test --tests` | 테스트 실행, 안전 | +| `./gradlew build` | 전체 빌드 | + +### 차단 (실행 불가) + +| 도구 | 이유 | +|---|---| +| `git push --force` | 원격 히스토리 파괴 위험 | +| `git reset --hard` | 로컬 변경 손실 위험 | +| `git clean` | 추적 안 된 파일 삭제 위험 | + +### 승인 팝업이 뜨는 경우 + +allowlist에 없는 Bash 명령(실제 커밋, 파일 쓰기, 서버 시작 등)은 실행 전 팝업이 뜹니다. 승인하면 그 명령만 1회 실행됩니다. + +--- + +## 7. CLAUDE.md — 프로젝트 가드레일 + +`CLAUDE.md`는 세션 시작 시 자동으로 로드되어 모든 작업에 적용됩니다. + +### 핵심 가드레일 요약 + +**항상 지키는 것**: +- 라이브 서비스를 깨지 않는다 +- dev에서 검증 후 prod 반영 +- FCM 경로 수정·실행 전 사전 경고 +- 프로덕션 DB 파괴 작업은 명시적 확인 후 + +**자유롭게 하는 것**: +- git 커밋·push·브랜치·머지 +- Docker 배포·인프라 설정 + +**응답 방식**: +- 한국어 응답·주석·커밋 +- 근거는 `파일:라인` 형식 (추정 금지) +- 장시간 작업은 중간 진행 공유 + +--- + +## 8. 실전 사용 예시 + +### 예시 1: 신규 기능 개발 (문제에 태그 복수 추가) + +``` +# 1. 계획 먼저 +/plan 문제 등록 API에 태그 복수 추가 지원 + +# → docs/plans/태그복수추가_plan.md 생성 +# → 계획서 검토 후 승인 + +# 2. 구현 +/feat 문제 등록 태그 복수 추가 + +# → 컴파일 자동 확인 +# → 커밋 전 요약 보고 + +# 3. 테스트 +/test ProblemService + +# → 사용자 격리 케이스 포함 테스트 작성 + 실행 + +# 4. 검증 +/check + +# → 4개 에이전트 병렬 검증 +# → 🔴🟠 항목 있으면 수정 후 재확인 + +# 5. 커밋 +/commit +``` + +### 예시 2: 버그 조사 + +``` +# 원인 모를 때 +/analysis 다른 사용자 문제가 조회되는 버그 + +# → 에이전트 병렬 조사 → 근본 원인 + 파일:라인 +``` + +### 예시 3: 성능 개선 + +``` +# 병목 진단 +/perf 복습 기록 목록 조회 + +# → N+1 등 발견 → /plan으로 개선 계획 수립 +``` + +### 예시 4: 코드 이해 + +``` +# 언제든 +/explain JWT 인증 흐름 +/explain ProblemController.getProblemList +``` + +### 예시 5: 작업 보고서 + +``` +# 기준 커밋 이후 변경사항 보고서 +/report 7edc25a + +# → docs/reports/문제등록개선_보고서.md 생성 +``` + +--- + +## 자주 묻는 것 + +**Q: `/feat` 없이 직접 구현 요청해도 되나?** +A: 됩니다. 다만 Claude는 계획 제시 후 승인을 기다립니다. 빠르게 가려면 `/plan` 먼저 해두면 더 매끄럽습니다. + +**Q: `/check`는 언제 쓰나?** +A: `/feat` 완료 직후 습관적으로. 특히 권한·FCM·쓰기 로직이 포함된 경우 필수입니다. + +**Q: 에이전트를 직접 호출할 수 있나?** +A: 기술적으로는 가능하지만, 커맨드를 통해 호출하는 것이 권장 방식입니다. 커맨드가 올바른 컨텍스트와 제약을 함께 전달합니다. + +**Q: `docs/plans/`에 계획서가 쌓이면 어떻게 하나?** +A: 완료된 작업의 계획서는 삭제하거나 `docs/plans/archive/`로 이동해도 됩니다. diff --git a/.claude/commands/analysis.md b/.claude/commands/analysis.md new file mode 100644 index 00000000..934523a2 --- /dev/null +++ b/.claude/commands/analysis.md @@ -0,0 +1,32 @@ +--- +description: 문제 원인 규명·분석 (읽기 전용). 코드 수정·커밋 금지. +argument-hint: "[분석할 증상이나 주제 — 없으면 3가지 질문을 먼저 물어봄]" +--- + +# /analysis — 원인 규명·분석 + +**읽기 전용 명령: 코드 수정·커밋 절대 금지.** + +## 시작 + +인자($ARGUMENTS)가 없거나 불명확하면, 먼저 아래 3가지를 물어보고 답을 받은 뒤 진행: +1. 무슨 문제가 발생했나? (증상·에러·예상과 다른 동작) +2. 원하는 결과는 무엇인가? +3. 관련 파일·경로·로그가 있으면 알려달라. + +## 조사 단계 + +1. **조사각도 분해** — 이 문제를 설명할 수 있는 가설 3~5개를 먼저 나열한다. +2. **병렬 조사** — `code-tracer` 에이전트와 필요 시 `permission-auditor`, `perf-reviewer`, `logic-reviewer`, `db-safety` 에이전트를 병렬로 투입해 각 가설을 독립적으로 검증한다. + - 각 에이전트는 Read, Grep, Glob, Bash(git log/diff/show) 도구만 사용. + - 근거는 반드시 `파일:라인` 형식으로 명시. 추정 금지. +3. **교차 반증** — 각 에이전트 결과를 서로 교차 검증: 가설 A를 지지하는 증거가 가설 B를 반박하는가? 모순·공백 식별. +4. **종합** — 확신도(높음/중간/낮음)와 함께 근본 원인 결론 도출. + +## 산출물 + +분석 보고서 (텍스트, 저장 불필요): +- **요약**: 한 문장 결론 +- **근거**: 파일:라인 목록 +- **근본 원인 + 확신도** +- **다음 단계 제안** (plan 또는 추가 조사 방향) \ No newline at end of file diff --git a/.claude/commands/check.md b/.claude/commands/check.md new file mode 100644 index 00000000..28a5f8ab --- /dev/null +++ b/.claude/commands/check.md @@ -0,0 +1,32 @@ +--- +description: 다관점 검증 (읽기 전용). 코드 수정·커밋 금지. +argument-hint: "[검증 대상 — 미지정 시 현재 git diff]" +--- + +# /check — 다관점 검증 + +**읽기 전용 명령: 코드 수정·커밋 절대 금지.** + +## 대상: $ARGUMENTS (미지정 시 현재 git diff) + +## 단계 + +1. **전문가 병렬 검증** — 아래 에이전트를 동시에 투입: + - `permission-auditor`: 사용자별 오답노트 데이터 격리, 인증·인가 누락 검증 + - `perf-reviewer`: N+1, JPA 풀스캔, 커넥션풀, 인덱스 누락 검증 + - `logic-reviewer`: 엣지/널, 동시성, 예외·롤백, 비즈 로직 정합성 검증 + - `db-safety`: FCM 실발송 경로, 잘못된 MySQL 쓰기, 데이터 오염 검증 + - **마이그레이션 파일이 포함되면**: `migration-safety` 추가 투입 + - **DTO·API 엔드포인트 변경이 포함되면**: `api-contract-checker` 추가 투입 + +2. **2차 반증** — 각 에이전트 결과를 교차 검토: + - A가 발견한 문제를 B가 놓쳤는가? + - 에이전트 간 충돌하는 판단이 있는가? → 재검토. + +3. **위험도 분류 보고**: + - 🔴 **즉시 수정 필요** (사용자 영향, 데이터 오염, 보안) + - 🟠 **배포 전 수정 권장** (성능 저하, 예외 미처리) + - 🟡 **다음 이터레이션** (마이너 개선) + - 🟢 **이상 없음** + +근거는 반드시 `파일:라인`. 추정 금지. diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 00000000..adfa4b16 --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,32 @@ +--- +description: 현재 변경분을 논리 단위로 분할하고 OnO 형식으로 실제 커밋까지 수행. +argument-hint: "[추가 맥락 — 선택]" +--- + +# /commit — 커밋 생성 + +**변경 범위를 확인한 뒤 논리 단위별로 `git add`와 `git commit`까지 수행한다.** + +## 맥락: $ARGUMENTS + +## 단계 + +1. `git diff HEAD`와 `git status`로 현재 변경 전체 파악. + +2. 변경을 논리 단위로 분할: + - 각 단위는 독립적으로 빌드 가능하고 단일 목적. + - 관련 없는 변경이 섞이지 않도록. + +3. 각 단위별 커밋 메시지 결정: + ``` + [태그] 한 줄 요약 + - 상세 1 + - 상세 2 + ``` + 태그: `[Feat]` / `[Fix]` / `[Refactor]` / `[Chore]` / `[Test]` / `[Docs]` / `[Perf]` + +4. 관련 없는 변경이 섞이지 않도록 단위별로 staging 대상을 분리한다. + +5. 각 단위마다 `git add` 후 `git commit`을 실행한다. + +6. 완료 후 생성된 커밋 해시, 커밋 메시지, 포함 파일을 보고한다. diff --git a/.claude/commands/explain.md b/.claude/commands/explain.md new file mode 100644 index 00000000..7ce1eb0d --- /dev/null +++ b/.claude/commands/explain.md @@ -0,0 +1,23 @@ +--- +description: 코드 흐름·원리 설명 (읽기 전용, 학습용). 코드 수정·커밋 금지. +argument-hint: "<설명할 기능·클래스·엔드포인트>" +--- + +# /explain — 코드 흐름·원리 설명 + +**읽기 전용 명령: 코드 수정·커밋 절대 금지.** + +## 대상: $ARGUMENTS + +## 단계 + +1. **`code-tracer` 에이전트** — 대상의 진입점부터 전체 호출 경로 추적: + - Controller → Service → Repository → 쿼리 레이어 + - 데이터 흐름, 변환 지점, 분기 조건 + +2. **흐름 설명**: + - 요청이 어떻게 처리되는가 (순서대로) + - **왜 이렇게 설계했는가** (도메인 이유, 성능 이유, 제약 조건) + - 주요 클래스·메서드의 역할 (`파일:라인` 포함) + +3. **다이어그램** (선택) — 흐름이 복잡하면 텍스트 시퀀스 다이어그램 추가. diff --git a/.claude/commands/feat.md b/.claude/commands/feat.md new file mode 100644 index 00000000..34333bf0 --- /dev/null +++ b/.claude/commands/feat.md @@ -0,0 +1,28 @@ +--- +description: 기능 구현. 계획 승인 후 실행. +argument-hint: "<작업 설명 또는 계획서 경로>" +--- + +# /feat — 기능 구현 + +## 작업: $ARGUMENTS + +## 단계 + +1. **조사 & 계획 확인** — 관련 계획서(`docs/plans/`)가 있으면 읽는다. 없으면 `code-tracer` 에이전트로 영향 범위 파악 후 간략 계획을 제시하고 **승인 대기**. + +2. **승인 확인** — 계획이 승인되지 않았다면 구현 시작 금지. "계획을 확인해주세요." 안내. + +3. **구현** — 승인된 계획에 따라 구현: + - 엔티티 변경 시 `./gradlew compileJava`로 컴파일 확인. + - 가드레일 준수: FCM 경로·프로덕션 DB 파괴 작업 전 경고. + - 사용자 격리(userId 소유권 검증) 누락 없이 구현. + - 근거 없는 코드 추가 금지. 각 변경은 계획서 단계에 대응. + +4. **컴파일 확인** — 구현 완료 후 `./gradlew compileJava` 실행. 오류 있으면 수정. + +5. **커밋 제안** — 논리 단위별 커밋 메시지 초안을 제시하고 사용자 확인 후 커밋. + 형식: `[Feat] 한 줄 요약\n- 상세` + (테스트는 /test로 분리) + +> 🟠 구현 완료 후 /test → /check 순서로 검증 권장. diff --git a/.claude/commands/issue.md b/.claude/commands/issue.md new file mode 100644 index 00000000..b4c56452 --- /dev/null +++ b/.claude/commands/issue.md @@ -0,0 +1,83 @@ +--- +description: AI-SIP/OnO_BACKEND 리포지토리에 GitHub 이슈를 생성한다. 텍스트 설명 또는 파일 경로를 받아 템플릿에 맞게 이슈를 작성한다. +argument-hint: "<이슈 설명 또는 파일 경로>" +--- + +# /issue — GitHub 이슈 생성 + +## 입력: $ARGUMENTS + +## 단계 + +### 1. 입력 파악 +- `$ARGUMENTS`가 존재하는 파일 경로이면 해당 파일을 읽어 내용을 이슈 소스로 사용한다. +- 파일 경로가 아니면 텍스트 그대로를 이슈 소스로 사용한다. +- 인자가 없으면 사용자에게 이슈 내용을 물어본다. + +### 2. 이슈 유형 판단 +입력 내용을 분석해 다음 중 하나로 분류한다: + +| 유형 | 사용 템플릿 | labels | 제목 prefix | +|---|---|---|---| +| 버그·오류·에러·crash·NPE | Bug Report Template | `bug` | `[bug]` | +| 기능 추가·신규 개발 | Common Issue Template | `feature` | `[feat]` | +| 리팩터·코드 정리 | Common Issue Template | `refactor` | `[refactor]` | +| 설정·환경·의존성·chore | Common Issue Template | `chore` | `[chore]` | +| 성능 개선 | Common Issue Template | `performance` | `[perf]` | +| 문서·주석 | Common Issue Template | `documentation` | `[docs]` | +| 테스트 | Common Issue Template | `test` | `[test]` | +| 기타·불명확 | Common Issue Template | (없음) | `[chore]` | + +### 3. 템플릿 채우기 + +**Bug Report Template** (버그 유형): +``` +## ⚙️ 어떤 버그인가요? +[버그 핵심 요약 — 1~2문장] + +### 스크린샷(선택) +[입력에 첨부 이미지·로그가 있으면 삽입, 없으면 섹션 생략] + +## 🔎 어떤 상황에서 발생한 버그인가요? +**Given** — [사전 조건] +**When** — [행동] +**Then** — [실제 결과 / 오류] + +## ✅ 예상 결과 +[정상적으로 동작해야 하는 결과] +``` + +**Common Issue Template** (그 외): +``` +## 📝 Description +[작업에 대한 간략한 설명] + +## ✅ TODO +- [ ] [할 일 1] +- [ ] [할 일 2] +(입력에서 구체적인 작업 항목을 추출해 체크리스트로 작성) + +## 💡 ETC (선택) +[기타 참고 사항, 고려할 점, 논의 필요 사항 — 없으면 섹션 생략] +``` + +### 4. 이슈 생성 전 미리보기 확인 +이슈를 생성하기 전에 다음 항목을 사용자에게 보여주고 확인한다: +- **제목**: 결정된 제목 +- **Labels**: 결정된 레이블 +- **본문 미리보기**: 작성된 본문 전체 + +사용자가 수정을 요청하면 반영한 뒤 재확인한다. 승인하면 5단계로 진행한다. + +### 5. GitHub 이슈 생성 +`mcp__github__issue_write` 도구를 사용해 이슈를 생성한다: +- `owner`: `AI-SIP` +- `repo`: `OnO_BACKEND` +- `method`: `create` +- `title`: 3단계에서 결정한 제목 +- `body`: 3단계에서 작성한 본문 +- `assignees`: `["KiSeungMin"]` (항상 고정) +- `labels`: 2단계에서 결정한 레이블 배열 + +### 6. 결과 보고 +생성된 이슈 URL을 사용자에게 알린다. \ No newline at end of file diff --git a/.claude/commands/migrate.md b/.claude/commands/migrate.md new file mode 100644 index 00000000..90925ec0 --- /dev/null +++ b/.claude/commands/migrate.md @@ -0,0 +1,34 @@ +--- +description: Flyway 마이그레이션 파일 작성 + 안전성 검토. DB 스키마 변경이 필요할 때 사용. +argument-hint: "<스키마 변경 내용 설명>" +--- + +# /migrate — DB 마이그레이션 + +## 작업: $ARGUMENTS + +## 단계 + +1. **기존 마이그레이션 파악** — `src/main/resources/db/migration/`에서 최신 버전 번호 확인. + `code-tracer`로 변경할 테이블의 현재 엔티티·쿼리 구조 파악. + +2. **마이그레이션 초안 작성** — 다음 번호(`V{N+1}__<설명>.sql`)로 SQL 초안 작성: + - 안전한 순서: `ADD COLUMN NULL` → 백필 UPDATE → `ALTER COLUMN NOT NULL` (한 파일 또는 단계별 분리) + - 대용량 테이블이면 락 영향 명시 + - 롤백 SQL 주석으로 포함 + +3. **`migration-safety` 에이전트로 검토** — 초안을 검토: + - 되돌리기 불가 작업 여부 + - 락 위험 + - 데이터 정합성 + - Flyway 파일 형식 + +4. **API 변경 수반 시** — DTO·엔드포인트도 변경된다면 `api-contract-checker` 에이전트 추가 투입: + - Flutter 앱에 breaking change 여부 판단 + +5. **검토 결과 보고 + 승인 대기** — 🔴·🟠 항목이 있으면 수정 후 재검토. + 안전 확인 후: "마이그레이션 파일을 저장할까요?" 확인. + +6. **파일 저장** — 승인 시 `V{N+1}__<설명>.sql` 파일 생성. + +> ⚠️ dev에서 먼저 적용 테스트 후 prod 반영. 프로덕션 Flyway는 자동 실행되므로 한 번 머지되면 되돌리기 어렵다. diff --git a/.claude/commands/perf.md b/.claude/commands/perf.md new file mode 100644 index 00000000..a81faf6e --- /dev/null +++ b/.claude/commands/perf.md @@ -0,0 +1,25 @@ +--- +description: 성능 병목 진단 (읽기 전용). 코드 수정·커밋 금지. +argument-hint: "<진단 대상 기능·엔드포인트·패키지>" +--- + +# /perf — 성능 병목 진단 + +**읽기 전용 명령: 코드 수정·커밋 절대 금지.** + +## 대상: $ARGUMENTS + +## 단계 + +1. **병렬 점검** — `perf-reviewer`와 `code-tracer` 에이전트를 동시에 투입: + - `perf-reviewer`: N+1 쿼리, JPA fetch 전략, 풀스캔, 커넥션풀 고갈, 데드락, 인덱스 누락, 페이지네이션 없는 대용량 조회 + - `code-tracer`: 대상 기능의 전체 호출 경로 추적, 쿼리 실행 지점 식별 + +2. **개선안 우선순위**: + - 🔴 즉시 개선 (사용자 체감 레이턴시·OOM 위험) + - 🟠 배포 전 개선 권장 + - 🟡 백로그 + +3. **각 개선안마다**: 문제 지점(`파일:라인`), 원인, 구체적 개선 방법, 예상 효과. + +> 진단 완료 후 /plan으로 개선 계획 수립 권장. diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md new file mode 100644 index 00000000..73b435c7 --- /dev/null +++ b/.claude/commands/plan.md @@ -0,0 +1,30 @@ +--- +description: 구현 계획서 작성 (구현 X). 계획서 파일을 저장한다. +argument-hint: "<작업 설명>" +--- + +# /plan — 구현 계획서 + +**읽기 전용 명령: 코드 수정·커밋 절대 금지. 계획서 파일만 저장.** + +## 작업: $ARGUMENTS + +## 단계 + +1. **조사** — `code-tracer` 에이전트로 관련 코드 흐름 파악. 필요 시 `permission-auditor`, `perf-reviewer`, `db-safety` 에이전트 추가 투입. 근거는 `파일:라인`. + - **DB 스키마 변경이 포함되면**: `migration-safety` 에이전트로 마이그레이션 안전성 사전 검토. + - **DTO·엔드포인트 변경이 포함되면**: `api-contract-checker` 에이전트로 Flutter 앱 breaking change 여부 확인. + +2. **계획서 작성** — 아래 섹션을 포함: + - **목적**: 한 줄 요약 + - **영향 범위**: 수정될 파일·패키지·레이어 목록 + - **구현 단계**: 번호 있는 순서 (각 단계 독립적으로 구현 가능하게) + - **위험 요소**: FCM 발송·데이터 마이그레이션·권한 변경 등 주의 지점 + - **테스트 방향**: 어떤 경우를 검증해야 하는가 (사용자 격리 포함) + - **커밋 분할 제안**: 논리 단위별 커밋 메시지 초안 + +3. **저장** — 계획서를 `docs/plans/<작업명>_plan.md`에 저장. 저장 경로를 사용자에게 알린다. + +4. **승인 대기** — "계획서를 검토해주세요. 승인하면 /feat으로 진행합니다." 라고 안내. + +> 🔴 계획서 검토는 가장 중요한 체크포인트다. 사용자 확인 전 구현 시작 금지. diff --git a/.claude/commands/pr.md b/.claude/commands/pr.md new file mode 100644 index 00000000..46ffea7e --- /dev/null +++ b/.claude/commands/pr.md @@ -0,0 +1,145 @@ +--- +description: 현재 브랜치의 개발 내역을 develop 브랜치로 merge 요청하는 GitHub PR 생성. +argument-hint: "<추가 설명 (선택)>" +--- + +# /pr — GitHub PR 생성 (→ develop) + +## 추가 설명: $ARGUMENTS + +## 주의사항 + +> ⚠️ **main 브랜치로는 절대 PR을 생성하지 않는다.** main merge는 프로덕션 자동 배포로 이어지므로 항상 사용자가 직접 수행한다. +> 이 명령어는 현재 브랜치 → `develop` PR만 생성한다. + +--- + +## 단계 + +### 1. 브랜치 상태 확인 +- `git branch --show-current`로 현재 브랜치 이름 확인 +- 현재 브랜치가 `main` 또는 `develop`이면 **즉시 중단**하고 작업 브랜치에서 실행해야 한다고 알린다 +- `git log develop..HEAD --oneline`으로 develop 대비 커밋 목록 확인 +- `git diff develop...HEAD`로 변경 내용 파악 +- `git status`로 미커밋 변경사항 확인 (있으면 사용자에게 명시) +- 원격 브랜치 존재 여부와 미push 커밋 확인 + +### 2. 변경 내용 분석 +커밋 로그와 diff를 바탕으로 다음을 파악한다: +- **작업 유형**: feat / fix / refactor / chore / perf / docs / test (복수 가능) +- **영향 범위**: 어떤 패키지·API·DB 스키마가 바뀌었는지 +- **연관 이슈 후보 추출**: 커밋 메시지와 브랜치명에서 이슈 번호 또는 키워드를 추출한다 + - 브랜치명 패턴: `feat/123-login` → 번호 `123`, 키워드 `login` + - 커밋 메시지 패턴: `close #123`, `fix #123`, `resolve #123` → 번호 `123` + - 번호가 없으면 작업 내용을 요약한 키워드를 추출한다 + +### 2-1. GitHub에서 연관 이슈 검색 +`mcp__github__.list_issues`를 사용해 `AI-SIP/OnO_BACKEND` 리포지토리의 **open 상태** 이슈를 검색한다: +- 2단계에서 추출한 이슈 번호나 키워드를 open issue의 번호, 제목, 본문과 비교한다 +- 검색 결과 중 이번 PR 작업과 연관성이 높은 이슈를 골라 연관 이슈 목록을 확정한다 +- PR에서 실제로 해결되는 이슈만 `close #이슈번호`로 연결한다 +- 단순 참고 이슈는 자동 종료되지 않도록 `close` 없이 참고 문장으로만 작성한다 +- 연관 이슈를 찾지 못하면 "없음"으로 처리한다 + +### 3. 레이블 결정 +변경 내용을 분석해 다음 중 하나 이상으로 레이블을 결정한다: + +| 작업 유형 | label | +|---|---| +| 신규 기능 | `feature` | +| 버그 수정 | `bug` | +| 리팩터링 | `refactor` | +| 설정·의존성·chore | `chore` | +| 성능 개선 | `performance` | +| 문서·주석 | `documentation` | +| 테스트 | `test` | + +결정한 label은 적용 전에 `mcp__github__.get_label`로 존재 여부를 확인하고, 존재하는 label만 적용한다. + +### 4. PR 제목 결정 +브랜치명과 커밋 내역을 종합해 PR 제목을 결정한다. +- 형식: `[타입] 핵심 변경 요약` (예: `[Feat] 오답노트 태그 필터 기능 추가`) +- 여러 타입이 섞이면 가장 비중이 큰 타입 하나를 사용 + +### 5. PR 본문 작성 +`.github/PULL_REQUEST_TEMPLATE.md` 구조를 그대로 따른다. 실제 변경 내용에 맞게 체크박스를 채운다: + +``` +## ✔️ 연관 이슈 +- close #이슈번호 ← 2-1단계에서 찾았고 PR merge 시 닫아야 하는 이슈마다 한 줄씩 기재 +- 없음 ← 관련 이슈가 없으면 기재 + +## 📝 작업 내용 +> [커밋 단위로 bullet 정리] + +## 👤 사용자 영향 +- [사용자 관점에서 달라지는 점 / 영향 없으면 "없음"] + +## 🔌 API 호환성 +- [ ] 요청/응답 DTO 변경 없음 +- [ ] 기존 Flutter 앱 버전과 호환 확인 +- [ ] breaking change 있음 + +## 🗄️ DB migration +- [ ] 없음 +- [ ] 있음: migration 파일과 기존 데이터 호환성 확인 + +## 🔐 인증/권한 +- [ ] 영향 없음 +- [ ] 사용자별 데이터 소유권 검증 확인 +- [ ] 관리자/특수 권한 영향 확인 + +## ✅ 검증 결과 +- [실행한 테스트·빌드 명령과 결과 / 실행하지 못한 검증과 남은 리스크] + +## 🚀 배포 리스크 +- [배포 시 주의할 점 / 없으면 "없음"] + +## ↩️ 롤백/대응 방법 +- [문제 발생 시 롤백 방법 / 해당 없으면 "없음"] + +### 스크린샷 (선택) +``` + +### 6. 생성 전 미리보기 확인 +PR을 생성하기 전에 다음 항목을 사용자에게 보여주고 확인을 받는다: +- **Base → Head**: `develop` ← 현재 브랜치명 +- **제목**: 결정된 제목 +- **Labels**: 결정된 레이블 +- **연관 이슈**: `close #이슈번호` 또는 없음 +- **본문 미리보기**: 작성된 본문 전체 + +사용자가 수정을 요청하면 반영한 뒤 재확인한다. 승인하면 다음 단계로 진행한다. + +### 7. 원격 브랜치 push +원격 브랜치가 없거나 미push 커밋이 있으면 직접 push한 뒤 PR 생성을 진행한다. + +```bash +git push -u origin $(git branch --show-current) +``` + +push 실패 시 인증, 권한, 네트워크 오류를 보고하고 PR 생성을 중단한다. + +### 8. GitHub PR 생성 +`mcp__github__.create_pull_request` 도구를 사용해 PR을 생성한다: +- `owner`: `AI-SIP` +- `repo`: `OnO_BACKEND` +- `title`: 4단계에서 결정한 제목 +- `body`: 5단계에서 작성한 본문 +- `head`: 현재 브랜치명 +- `base`: `develop` (절대 고정 — main 불가) + +Reviewers는 지정하지 않는다. + +### 8-1. Assignee/Label 설정 +PR 생성 후 생성된 PR 번호에 `mcp__github__.issue_write` 도구를 `method=update`로 호출한다: +- `owner`: `AI-SIP` +- `repo`: `OnO_BACKEND` +- `issue_number`: 생성된 PR 번호 +- `assignees`: `["KiSeungMin"]` (항상 고정) +- `labels`: 3단계에서 결정하고 존재 확인된 label 배열 + +milestone, project, issue fields, type 등 나머지 항목은 지정하지 않는다. + +### 9. 결과 보고 +생성된 PR URL, base/head branch, assignee, 적용 label, 연결한 issue, `main`을 target으로 사용하지 않았다는 점을 사용자에게 알린다. diff --git a/.claude/commands/report.md b/.claude/commands/report.md new file mode 100644 index 00000000..60940cba --- /dev/null +++ b/.claude/commands/report.md @@ -0,0 +1,22 @@ +--- +description: 작업 결과 보고서 작성 및 저장. +argument-hint: "<기준 커밋 해시>" +--- + +# /report — 결과 보고서 + +## 기준: $ARGUMENTS (커밋 해시) + +## 단계 + +1. **변경 분석** — `code-tracer` 에이전트로 `<커밋>..HEAD` 범위의 변경 내용 분석: + - 수정된 파일·레이어 목록 + - 각 변경의 목적과 영향 + - Before/After 비교 가능한 핵심 지점 + +2. **`report-writer` 에이전트로 보고서 작성**: + - 존댓말, 핵심 위주, Before/After 표 포함 + - 테스트 코드 내용 제외 (결과만) + - 외부에 공유 가능한 수준으로 + +3. **저장 위치 확인** — 보고서 파일명(`<개발사항>_보고서.md`)을 제안하고 저장 위치를 사용자에게 물어본 뒤 저장. diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 00000000..bc378f12 --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,25 @@ +--- +description: 테스트 작성 및 실행. +argument-hint: "[테스트 대상 — 미지정 시 현재 변경분]" +--- + +# /test — 테스트 작성 + +## 대상: $ARGUMENTS (미지정 시 현재 git diff 변경분) + +## 단계 + +1. **기존 테스트 품질 확인** — 대상 도메인의 기존 테스트를 먼저 읽는다. + 좋으면 관례를 따르고, 그렇지 않으면 새로 깔끔하게 작성. + +2. **`test-writer` 에이전트 투입** — 아래 원칙으로 테스트 작성: + - test 프로필 + 격리 DB(H2 또는 전용 test DB) 사용. + - **사용자 격리 필수**: 다른 사용자의 데이터에 접근할 수 없음을 반드시 검증. + - 권한·필터·검색 조건의 모든 경우(정상/경계/실패) 망라. + - 기존 테스트 복붙 금지 — 독립적으로 작성. + - 단위 테스트 + DB 연동 테스트 위주. 외부 연동(FCM 등)은 mock. + +3. **해당 테스트만 실행** — `./gradlew test --tests "대상클래스"` 실행 후 결과 확인. + 실패 시 원인 파악 및 수정. + +4. **통과 확인** — 모든 작성 테스트가 통과하면 결과 보고. diff --git a/.claude/hooks/db_safety_check.py b/.claude/hooks/db_safety_check.py new file mode 100644 index 00000000..87b38764 --- /dev/null +++ b/.claude/hooks/db_safety_check.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +""" +PreToolUse hook: Bash 명령에서 위험한 DB 작업 패턴 감지 시 차단. +Claude가 DROP/TRUNCATE/무조건 DELETE 등을 실행하려 할 때 경고 후 중단. +""" +import sys +import json +import re + +try: + data = json.load(sys.stdin) +except Exception: + sys.exit(0) +cmd = data.get("tool_input", {}).get("command", "") + +DANGEROUS = [ + (r"DROP\s+TABLE", "DROP TABLE"), + (r"TRUNCATE\s+(TABLE\s+)?[`\w][`\w.]*", "TRUNCATE"), + (r"ALTER\s+TABLE\s+[`\w][`\w.]*\s+DROP", "DROP COLUMN/INDEX"), + (r"DROP\s+DATABASE", "DROP DATABASE"), + (r"flyway\s+clean\b", "Flyway clean"), + (r"ddl-auto\s*[:=]\s*create-drop\b", "ddl-auto=create-drop"), + (r"ddl-auto\s*[:=]\s*create\b", "ddl-auto=create"), + (r"docker\s+compose\s+down\b.*\s-v\b", "docker compose down -v"), + (r"docker-compose\b.*\sdown\b.*\s-v\b", "docker-compose down -v"), + (r"\bmysql\b.*\b(prod|production)\b", "production mysql command"), +] + +found = [label for pattern, label in DANGEROUS if re.search(pattern, cmd, re.IGNORECASE)] + +# WHERE 없는 DELETE — 백틱·스키마 프리픽스 포함, WHERE 절 있으면 제외 +if (re.search(r"DELETE\s+FROM", cmd, re.IGNORECASE) + and not re.search(r"\bWHERE\b", cmd, re.IGNORECASE)): + found.append("WHERE 없는 DELETE") + +# WHERE 없는 UPDATE는 운영 데이터 대량 변경으로 이어질 수 있다. +if (re.search(r"\bUPDATE\s+[`.\w]+\s+SET\b", cmd, re.IGNORECASE) + and not re.search(r"\bWHERE\b", cmd, re.IGNORECASE)): + found.append("WHERE 없는 UPDATE") + +if found: + print(f"🚨 위험한 DB 작업 감지: {', '.join(found)}") + print(f" 명령: {cmd[:200]}") + print(" 프로덕션 영향 범위를 확인한 뒤 명시적으로 허용해 주세요.") + sys.exit(2) diff --git a/.codex/USAGE.md b/.codex/USAGE.md new file mode 100644 index 00000000..4377d4f3 --- /dev/null +++ b/.codex/USAGE.md @@ -0,0 +1,506 @@ +# OnO Codex 사용법 + +이 폴더는 OnO 백엔드 레포에서 Codex를 일관된 방식으로 쓰기 위한 설정과 스킬을 담는다. + +## 1. 설치된 구성 + +```text +.codex/ +├── review.config.toml +├── build.config.toml +├── scripts/codex-notify-macos.sh +└── skills/ + ├── analysis/ + ├── check/ + ├── commit/ + ├── feat/ + ├── migrate/ + ├── plan/ + ├── pr/ + ├── report/ + ├── test/ + ├── test-writer/ + ├── perf/ + ├── perf-audit/ + └── explain/ +``` + +실제 Codex CLI가 바로 읽는 위치는 `~/.codex`다. 현재 이 레포의 `.codex` 내용은 다음 위치에도 설치되어 있다. + +```text +~/.codex/review.config.toml +~/.codex/build.config.toml +~/.codex/skills/analysis +~/.codex/skills/check +~/.codex/skills/commit +~/.codex/skills/feat +~/.codex/skills/migrate +~/.codex/skills/plan +~/.codex/skills/pr +~/.codex/skills/report +~/.codex/skills/test +~/.codex/skills/test-writer +~/.codex/skills/perf +~/.codex/skills/perf-audit +~/.codex/skills/explain +~/.codex/scripts/codex-notify-macos.sh +``` + +## 2. 프로파일 사용 + +현재 로컬 Codex CLI 기준으로 별도 config 파일은 `--profile-v2`로 사용한다. + +### 분석/검토 모드 + +코드를 수정하지 않고 계획, 검토, 설명, 위험 분석을 할 때 사용한다. + +```bash +codex --profile-v2 review +``` + +설정 내용: + +```toml +model_reasoning_effort = "xhigh" +sandbox_mode = "read-only" +approval_policy = "on-request" +``` + +사용 예: + +```bash +codex --profile-v2 review +``` + +그 다음 Codex 안에서: + +```text +check 현재 변경사항 위험 검토해줘 +``` + +또는 빌트인 리뷰: + +```text +/review +``` + +### 구현/테스트 모드 + +파일 수정, 테스트 작성, 빌드 검증이 필요한 작업에 사용한다. + +```bash +codex --profile-v2 build +``` + +설정 내용: + +```toml +model_reasoning_effort = "high" +sandbox_mode = "workspace-write" +approval_policy = "on-request" +``` + +사용 예: + +```bash +codex --profile-v2 build +``` + +그 다음 Codex 안에서: + +```text +feat docs/...의 구현 명세서를 기준으로 기능 구현해줘 +``` + +## 3. 커스텀 명령어 스킬 사용법 + +스킬은 명시적으로 `$skill-name` 형태로 호출할 수 있고, 요청 내용이 description과 맞으면 자동으로 호출될 수 있다. + +현재 `.claude/commands`와 맞춰 제공하는 주요 명령어 이름: + +```text +analysis check commit explain feat migrate perf plan pr report test +``` + +보조 스킬: + +```text +test-writer perf-audit +``` + +공통 규칙: + +- 커스텀 명령어에 인자를 넣지 않으면 기본적으로 `git status`와 `git diff HEAD` 기준의 현재 변경사항을 대상으로 수행한다. +- `analysis`, `check`, `feat`는 완료 직전에 반드시 아래 자가 반증 질문에 답한 뒤 결론 또는 구현 결과를 확정한다. + +```text +내가 놓쳤을 가능성이 큰 것은? +이 변경이 실제 사용자에게 깨질 수 있는 경로는? +이 결론이 틀렸다면 어떤 조건 때문인가? +``` + +### analysis + +문제 원인을 읽기 전용으로 분석할 때 사용한다. + +```text +$analysis 소셜 로그인 후 특정 사용자만 401이 나는 원인 분석해줘 +``` + +인자를 생략하면 현재 변경사항을 대상으로 분석한다. + +```text +$analysis +``` + +수행 방식: + +- 가능한 원인 가설을 3~5개로 나눈다. +- 관련 코드, 설정, 로그, git diff/log를 읽기 전용으로 확인한다. +- 각 가설을 `파일:라인` 근거로 지지하거나 반박한다. +- 완료 직전에 자가 반증 3문항에 답하고 결론을 수정할지 판단한다. +- 근본 원인, 확신도, 다음 단계를 보고한다. + +### check + +현재 변경사항이나 특정 커밋 이후 변경을 다관점으로 검토할 때 사용한다. + +```text +$check 현재 변경사항 위험 검토해줘 +``` + +인자를 생략해도 현재 변경사항을 대상으로 검토한다. + +```text +$check +``` + +또는: + +```text +$check abc1234 이후 변경사항 검토해줘 +``` + +수행 방식: + +- `git status`, `git diff`, 필요 시 `git log/show`로 변경 범위를 좁힌다. +- 사용자 데이터 격리, 권한, N+1, 풀스캔, 트랜잭션, FCM/메일 실발송, DB schema 안전성을 확인한다. +- 1차 결론을 다시 반증해 실제 도달 가능한 문제인지 확인한다. +- 완료 직전에 자가 반증 3문항에 답하고 위험도 판단을 수정할지 확인한다. +- 위험도 `🔴/🟠/🟡/🟢` 순서로 보고한다. + +### commit + +현재 변경사항의 커밋 단위와 메시지를 정리하고 실제 커밋까지 수행할 때 사용한다. + +```text +$commit 현재 변경사항 커밋해줘 +``` + +수행 방식: + +- `git status`, `git diff HEAD`로 변경 전체를 파악한다. +- 서로 독립적인 논리 단위로 나눈다. +- 논리 단위별로 필요한 파일만 `git add`하고 OnO 커밋 메시지 형식으로 `git commit`을 수행한다. + +```text +[Feat] 구현 타이틀 - 구현 상세1 구현 상세2 +``` + +### feat + +승인된 계획서나 명확한 작업 설명을 기준으로 기능을 구현할 때 사용한다. + +```text +$feat docs/<기능폴더>/<명세서>.md 기준으로 구현해줘 +``` + +인자를 생략하면 현재 변경사항을 읽고 남은 구현, 정리, 검증 대상을 파악한다. + +```text +$feat +``` + +수행 방식: + +- 기준 구현 명세서를 먼저 확인한다. +- 명세서가 불명확하면 어떤 문서를 기준으로 할지 묻는다. +- API 계약, DTO, validation, 예외 처리, transaction, 권한, 성능, 데이터 정합성을 확인하며 구현한다. +- 가능한 범위에서 테스트, 빌드, 정적 검사를 실행한다. +- 완료 직전에 자가 반증 3문항에 답하고 추가 수정 또는 리스크 보고가 필요한지 확인한다. +- 완료 후 사용자 영향, 검증 결과, 남은 리스크, 추천 커밋 메시지를 보고한다. + +### plan + +구현 전 명세서를 작성할 때 사용한다. 코드는 수정하지 않고 `docs` 아래에 계획서를 만든다. + +```text +$plan 학습 기록 검색 API 필터 기능 구현 계획 작성해줘 +``` + +또는: + +```text +plan 학습 기록 검색 API 필터 기능 +``` + +수행 방식: + +- 관련 코드와 문서를 먼저 확인한다. +- API 계약, 데이터 흐름, 에러 처리, 테스트 방법, 배포 리스크를 정리한다. +- `/Users/ksm/programing/sw_maestro/OnO_BACKEND/backend/docs` 아래 기능별 폴더에 markdown을 작성한다. + +### migrate + +DB schema 변경과 Flyway migration이 필요할 때 사용한다. + +```text +$migrate review_schedule에 알림 실패 사유 컬럼 추가 +``` + +수행 방식: + +- 기존 migration 버전과 관련 entity/repository/query/API 영향을 먼저 확인한다. +- H2 인메모리가 아닌 환경에 `ddl-auto=create` 또는 `create-drop`을 추가하지 않는다. +- 안전한 migration 순서, backfill, rollback/대응 방법, dev/prod 적용 리스크를 정리한다. +- 운영 영향이 있는 SQL 파일은 저장 전 확인한다. + +### pr + +현재 브랜치의 개발 내역을 `develop` 브랜치로 merge 요청하는 GitHub PR로 직접 생성할 때 사용한다. + +```text +$pr abc1234 +``` + +수행 방식: + +- `git log`, `git diff`, `git status`로 커밋된 변경과 미커밋 변경을 분리한다. +- 원격 브랜치가 없거나 미push 커밋이 있으면 `git push -u origin 현재브랜치`로 직접 push한다. +- `.github/PULL_REQUEST_TEMPLATE.md`를 읽고 같은 구조로 작성한다. +- 사용자 영향, API 호환성, DB migration, 인증/권한, 검증 결과, 배포 리스크, 롤백 방법을 포함한다. + +### test-writer + +테스트 구현 원칙을 더 강하게 적용할 때 사용하는 보조 스킬이다. + +```text +$test-writer 이번 변경에 대한 API 통합 테스트 추가해줘 +``` + +수행 방식: + +- 기존 테스트 구조와 test profile을 먼저 확인한다. +- validation 실패, 권한 실패, null/empty 입력, DB 반영을 위험도에 맞춰 검증한다. +- MySQL/PostgreSQL 환경에 `ddl-auto=create` 또는 `create-drop`을 추가하지 않는다. + +### test + +`.claude/commands/test.md`에 대응하는 테스트 작성/실행 명령이다. + +```text +$test 현재 변경사항에 필요한 테스트 작성하고 실행해줘 +``` + +수행 방식: + +- 대상이 없으면 현재 git diff 기준으로 테스트 대상을 정한다. +- 기존 테스트 구조와 test profile을 확인한다. +- 사용자 격리, 권한 실패, validation 실패, null/empty, 예외 케이스를 위험도에 맞춰 포함한다. +- 관련 테스트만 우선 실행하고 결과를 보고한다. + +### report + +특정 커밋부터 현재까지의 작업 보고서나 PR 초안을 작성할 때 사용한다. + +```text +$report abc1234 이후 변경사항 보고서 작성해줘 +``` + +수행 방식: + +- `git log`, `git diff`, `git status`로 커밋된 변경과 미커밋 변경을 구분한다. +- 변경사항 요약, 사용자 영향, 검증 결과, 배포 리스크를 정리한다. +- 저장 위치가 불명확하면 먼저 확인한다. + +### perf + +`.claude/commands/perf.md`에 대응하는 성능 병목 진단 명령이다. + +```text +$perf 오답노트 목록 조회 API 성능 병목 진단해줘 +``` + +수행 방식: + +- 대상 기능의 호출 경로와 쿼리 실행 지점을 추적한다. +- N+1, fetch 전략, 풀스캔, pagination 누락, index 부재, 커넥션 풀 고갈 가능성을 확인한다. +- `🔴/🟠/🟡` 우선순위로 개선안을 보고한다. + +### perf-audit + +성능 문제를 더 넓게 점검하는 보조 스킬이다. `perf`와 같은 목적이지만 Flutter rebuild/jank까지 명시적으로 포함한다. + +```text +$perf-audit 오답노트 목록 조회 API가 느릴 수 있는지 확인해줘 +``` + +수행 방식: + +- controller/service/repository/query 흐름을 따라간다. +- pagination, index, N+1, transaction 범위, 외부 호출을 확인한다. +- 개선안은 운영 영향과 적용 난이도 순서로 정리한다. + +### explain + +코드 흐름이나 설계 판단을 학습 목적으로 설명받을 때 사용한다. + +```text +$explain StudyGroup 가입 요청 API 흐름 설명해줘 +``` + +수행 방식: + +- 진입점부터 service, repository, 응답 DTO까지 호출 경로를 추적한다. +- 중요한 근거는 파일과 라인으로 설명한다. +- API 계약, validation, 예외 처리, 트랜잭션, 보안 포인트를 함께 짚는다. + +## 4. 빌트인 리뷰 사용 + +Codex CLI에는 리뷰 명령이 있다. + +대화형 Codex 안에서: + +```text +/review +``` + +터미널에서 비대화형으로: + +```bash +codex --profile-v2 review review +``` + +OnO 백엔드 변경 검토 시에는 이런 기준을 함께 요청한다. + +```text +사용자 데이터 격리, 권한 누락, N+1, 풀스캔, 트랜잭션, FCM/메일 실발송, DB schema 안전성 기준으로 위험도를 나눠 검토해줘. 발견한 문제는 파일과 라인 근거를 포함해줘. +``` + +## 5. codex exec 사용 + +반복 검증이나 릴리즈 게이트처럼 비대화형 실행이 필요할 때 사용한다. + +```bash +codex --profile-v2 review exec "현재 변경을 OnO 위험 체크리스트로 검토하고, 사용자 데이터 손상이나 권한 누락 가능성이 있으면 명확히 보고해줘" +``` + +백엔드 배포 전 예: + +```bash +codex --profile-v2 review exec "현재 변경을 데이터 격리, N+1, 풀스캔, 트랜잭션, FCM/메일 실발송, ddl-auto 변경 기준으로 검토해줘" +``` + +구현 작업을 비대화형으로 맡길 때는 `build`를 사용한다. + +```bash +codex --profile-v2 build exec "docs/<기능폴더>/<명세서>.md 기준으로 구현하고 관련 테스트를 실행해줘" +``` + +운영 서비스 변경은 비대화형 구현보다 대화형 `build` 모드가 더 안전하다. + +## 6. 작업 완료 알림 + +전역 config의 `notify`는 다음 래퍼를 사용한다. + +```text +~/.codex/scripts/codex-notify-macos.sh +``` + +동작: + +- 기존 Computer Use `turn-ended` 알림을 그대로 호출한다. +- 추가로 macOS 알림을 띄운다. +- macOS 알림 실패 시 터미널 벨로 폴백한다. + +수동 테스트: + +```bash +~/.codex/scripts/codex-notify-macos.sh \ + "/Users/ksm/.codex/computer-use/Codex Computer Use.app/Contents/SharedSupport/SkyComputerUseClient.app/Contents/MacOS/SkyComputerUseClient" \ + turn-ended \ + '{"type":"agent-turn-complete"}' +``` + +알림이 보이지 않으면 macOS 시스템 설정에서 Terminal, Codex, 또는 사용 중인 터미널 앱의 알림 권한을 확인한다. + +## 7. 설정 동기화 + +이 레포의 `.codex`를 수정한 뒤 실제 Codex 설정에도 반영하려면 같은 내용을 `~/.codex`로 복사해야 한다. + +```bash +cp .codex/review.config.toml ~/.codex/review.config.toml +cp .codex/build.config.toml ~/.codex/build.config.toml +cp -R .codex/skills/analysis ~/.codex/skills/ +cp -R .codex/skills/check ~/.codex/skills/ +cp -R .codex/skills/commit ~/.codex/skills/ +cp -R .codex/skills/feat ~/.codex/skills/ +cp -R .codex/skills/plan ~/.codex/skills/ +cp -R .codex/skills/report ~/.codex/skills/ +cp -R .codex/skills/test ~/.codex/skills/ +cp -R .codex/skills/test-writer ~/.codex/skills/ +cp -R .codex/skills/perf ~/.codex/skills/ +cp -R .codex/skills/perf-audit ~/.codex/skills/ +cp -R .codex/skills/explain ~/.codex/skills/ +cp .codex/scripts/codex-notify-macos.sh ~/.codex/scripts/codex-notify-macos.sh +chmod +x ~/.codex/scripts/codex-notify-macos.sh +``` + +현재 전역 config 백업 파일: + +```text +~/.codex/config.toml.bak.codex-setup-kit-20260604 +``` + +## 8. 추천 작업 흐름 + +새 기능: + +```text +codex --profile-v2 review +plan <기능 설명> +``` + +이후: + +```text +codex --profile-v2 build +feat <작성된 명세서 경로> +``` + +마지막 검토: + +```text +codex --profile-v2 review +/review +``` + +커밋 메시지 형식: + +```text +[Feat] 구현 타이틀 - 구현 상세1 구현 상세2 +``` + +버그 수정: + +```text +codex --profile-v2 build +$test-writer 버그 재현 테스트를 먼저 추가하고 수정해줘 +``` + +성능 점검: + +```text +codex --profile-v2 review +$perf-audit 느린 API 후보를 쿼리와 트랜잭션 기준으로 점검해줘 +``` diff --git a/.codex/build.config.toml b/.codex/build.config.toml new file mode 100644 index 00000000..5491007e --- /dev/null +++ b/.codex/build.config.toml @@ -0,0 +1,3 @@ +model_reasoning_effort = "high" +sandbox_mode = "workspace-write" +approval_policy = "on-request" diff --git a/.codex/review.config.toml b/.codex/review.config.toml new file mode 100644 index 00000000..7820deaf --- /dev/null +++ b/.codex/review.config.toml @@ -0,0 +1,3 @@ +model_reasoning_effort = "xhigh" +sandbox_mode = "read-only" +approval_policy = "on-request" diff --git a/.codex/scripts/agent-config-lint.sh b/.codex/scripts/agent-config-lint.sh new file mode 100755 index 00000000..8c9c03d9 --- /dev/null +++ b/.codex/scripts/agent-config-lint.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail=0 + +error() { + printf 'ERROR: %s\n' "$1" >&2 + fail=1 +} + +require_file() { + if [ ! -f "$1" ]; then + error "missing required file: $1" + fi +} + +primary_commands=( + analysis + check + commit + explain + feat + migrate + perf + plan + pr + report + test +) + +for command in "${primary_commands[@]}"; do + require_file ".claude/commands/${command}.md" + require_file ".codex/skills/${command}/SKILL.md" + require_file ".codex/skills/${command}/agents/openai.yaml" +done + +require_file ".codex/skills/perf-audit/SKILL.md" +require_file ".codex/skills/test-writer/SKILL.md" + +for required in "사용자 영향" "API 호환성" "DB migration" "인증/권한" "검증 결과" "배포 리스크" "롤백"; do + if ! grep -q "$required" .github/PULL_REQUEST_TEMPLATE.md; then + error "PR template is missing section text: $required" + fi +done + +if grep -q '^/AGENTS\.md$' .gitignore; then + error "AGENTS.md is ignored; shared agent guidance should be trackable" +fi + +tracked_secret_files="$( + git ls-files | grep -E '(^|/)(\.env(\..*)?|FirebaseAdminKey\.json|application(-(dev|prod|local|test))?\.ya?ml|application(-(dev|prod|local|test))?\.properties)$' || true +)" + +if [ -n "$tracked_secret_files" ]; then + printf '%s\n' "$tracked_secret_files" >&2 + error "secret/config files are tracked" +fi + +secret_hits="$( + git grep -n -E -- \ + '-----BEGIN (RSA |EC |OPENSSH |PRIVATE )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}' \ + -- ':!*.md' ':!.codex/scripts/agent-config-lint.sh' || true +)" + +if [ -n "$secret_hits" ]; then + printf '%s\n' "$secret_hits" >&2 + error "possible secret material found in tracked files" +fi + +if [ "$fail" -ne 0 ]; then + exit 1 +fi + +printf 'Agent configuration lint passed.\n' diff --git a/.codex/scripts/codex-notify-macos.sh b/.codex/scripts/codex-notify-macos.sh new file mode 100755 index 00000000..dbf6104d --- /dev/null +++ b/.codex/scripts/codex-notify-macos.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -u + +sky_client="${1:-}" +sky_event="${2:-turn-ended}" +payload="${3:-}" + +if [ -n "$sky_client" ] && [ -x "$sky_client" ]; then + "$sky_client" "$sky_event" "$payload" >/dev/null 2>&1 & +fi + +message="Codex 작업이 완료되었습니다." +title="Codex" + +if ! /usr/bin/osascript -e "display notification \"$message\" with title \"$title\" sound name \"Glass\"" >/dev/null 2>&1; then + printf '\a' +fi diff --git a/.codex/skills/analysis/SKILL.md b/.codex/skills/analysis/SKILL.md new file mode 100644 index 00000000..928b487e --- /dev/null +++ b/.codex/skills/analysis/SKILL.md @@ -0,0 +1,28 @@ +--- +name: analysis +description: OnO 문제 원인 규명과 코드 분석이 필요할 때 사용. 사용자가 "analysis <증상>" 또는 원인 분석을 요청하면 코드를 수정하지 않고 보고한다. 인자가 없으면 현재 git 변경사항을 대상으로 분석한다. +--- + +# OnO Analysis + +## Workflow + +1. 인자가 없으면 `git status`와 `git diff HEAD` 기준으로 현재 변경사항을 분석 대상으로 삼는다. +2. 증상, 원하는 결과, 관련 파일/로그가 불명확하고 현재 변경사항에서도 분석 대상이 좁혀지지 않으면 먼저 짧게 확인한다. +3. 가능한 원인 가설을 3~5개로 나눈다. +4. 관련 코드 흐름, 설정, 로그, git diff/log를 읽기 전용으로 조사한다. +5. 각 가설을 `파일:라인` 근거로 지지하거나 반박한다. +6. 1차 결론을 스스로 반증한다. 상위 레이어에서 막히는지, 실제 호출 가능한지, 다른 설정이 우선하는지 확인한다. +7. 완료 직전에 아래 자가 반증 질문에 답하고, 필요한 경우 결론을 수정한다. + - 내가 놓쳤을 가능성이 큰 것은? + - 이 변경이 실제 사용자에게 깨질 수 있는 경로는? + - 이 결론이 틀렸다면 어떤 조건 때문인가? +8. 코드는 수정하지 않는다. + +## Output + +- 한 문장 요약 +- 근거 목록 +- 근본 원인과 확신도 +- 자가 반증 결과 +- 다음 단계 제안 diff --git a/.codex/skills/analysis/agents/openai.yaml b/.codex/skills/analysis/agents/openai.yaml new file mode 100644 index 00000000..d82b49df --- /dev/null +++ b/.codex/skills/analysis/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Analysis" + short_description: "OnO 문제 원인 분석" + default_prompt: "Use $analysis to investigate this OnO issue." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/check/SKILL.md b/.codex/skills/check/SKILL.md new file mode 100644 index 00000000..697b534c --- /dev/null +++ b/.codex/skills/check/SKILL.md @@ -0,0 +1,43 @@ +--- +name: check +description: Use for read-only OnO change review. Trigger on "check", "check ", risk review, or pre-deploy review. If no argument is provided, review current git changes. +--- + +# OnO Check + +## Scope + +- 인자가 있으면 해당 커밋/범위부터 현재까지 확인한다. +- 인자가 없으면 `git status`와 `git diff HEAD` 기준의 현재 변경사항을 확인한다. +- 코드는 수정하지 않는다. + +## Workflow + +1. `git status`, `git diff`, 필요 시 `git log/show`로 변경 범위를 좁힌다. +2. 변경된 파일과 영향받는 사용자 흐름 기준으로 검토한다. +3. 1차 검토: + - 사용자 데이터 격리, 인증/인가 누락 + - N+1, 풀스캔, pagination/index, connection pool 위험 + - null/empty, 예외 처리, rollback, 동시성, 비즈니스 정합성 + - FCM/메일 실발송, 잘못된 DB write, schema 안전성 +4. 2차 반증: + - 발견한 문제가 실제로 도달 가능한지 확인한다. + - 상위 validation, 권한 체크, transaction 경계가 이미 막는지 확인한다. + - 반대로 1차에서 놓친 사용자 영향 흐름이 있는지 확인한다. +5. 완료 직전에 아래 자가 반증 질문에 답하고, 필요한 경우 위험도 판단을 수정한다. + - 내가 놓쳤을 가능성이 큰 것은? + - 이 변경이 실제 사용자에게 깨질 수 있는 경로는? + - 이 결론이 틀렸다면 어떤 조건 때문인가? +6. 가능한 경우 관련 테스트/정적 검사/빌드 명령을 제안하거나 실행한다. 읽기 전용 프로파일이면 실행 가능 범위만 명시한다. + +## Output + +위험도 순서로 보고한다. + +- 🔴 즉시 수정 필요 +- 🟠 배포 전 수정 권장 +- 🟡 다음 이터레이션 +- 🟢 이상 없음 + +각 항목은 `파일:라인`, 사용자 영향, 수정 방향을 포함한다. +마지막에 자가 반증 결과를 짧게 포함한다. diff --git a/.codex/skills/check/agents/openai.yaml b/.codex/skills/check/agents/openai.yaml new file mode 100644 index 00000000..10c1dcfd --- /dev/null +++ b/.codex/skills/check/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Check" + short_description: "OnO 변경 위험 검토" + default_prompt: "Use $check to review this OnO change." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/commit/SKILL.md b/.codex/skills/commit/SKILL.md new file mode 100644 index 00000000..1d3683d2 --- /dev/null +++ b/.codex/skills/commit/SKILL.md @@ -0,0 +1,35 @@ +--- +name: commit +description: 현재 변경사항의 커밋 단위와 OnO 커밋 메시지를 정리하고 실제 git commit까지 수행할 때 사용. 인자가 없어도 현재 git 변경사항을 대상으로 한다. +--- + +# OnO Commit + +## Workflow + +1. `git status`와 `git diff HEAD`로 현재 변경 전체를 파악한다. +2. 사용자 변경과 이번 작업 변경을 구분한다. 임의로 되돌리지 않는다. +3. 변경을 독립적으로 빌드/검토 가능한 논리 단위로 나눈다. +4. 각 단위에 커밋 메시지를 결정한다. +5. 논리 단위별로 필요한 파일만 `git add`하고 `git commit`을 수행한다. +6. 커밋 후 `git status`로 작업 트리에 남은 변경사항을 확인한다. + +## Message Format + +```text +[Feat] 구현 타이틀 - 구현 상세1 구현 상세2 +``` + +태그 후보: + +```text +[Feat] [Fix] [Refactor] [Chore] [Test] [Docs] [Perf] +``` + +## Output + +- 생성한 커밋 단위 +- 각 단위에 포함할 파일 +- 커밋 메시지 +- 커밋 해시 +- 작업 트리에 남은 변경사항 여부 diff --git a/.codex/skills/commit/agents/openai.yaml b/.codex/skills/commit/agents/openai.yaml new file mode 100644 index 00000000..505b230c --- /dev/null +++ b/.codex/skills/commit/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Commit" + short_description: "OnO 커밋 생성" + default_prompt: "Use $commit to create git commits." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/explain/SKILL.md b/.codex/skills/explain/SKILL.md new file mode 100644 index 00000000..0fb684b8 --- /dev/null +++ b/.codex/skills/explain/SKILL.md @@ -0,0 +1,20 @@ +--- +name: explain +description: 사용자가 OnO 백엔드 또는 Flutter 코드 흐름, 원리, 호출 경로, 설계 판단을 학습 목적으로 설명해 달라고 요청할 때 사용한다. +--- + +# OnO Explain + +## Workflow + +1. 추측으로 설명하지 않고 관련 파일과 호출 경로를 먼저 확인한다. +2. 설명은 `요청 진입점 -> 핵심 로직 -> DB/API/외부 연동 -> 응답/상태 반영` 순서로 정리한다. +3. 중요한 근거는 파일 경로와 라인으로 연결한다. +4. 백엔드 개발자 관점에서 API 계약, DTO, validation, 예외 처리, 트랜잭션, 보안, 성능 포인트를 짧게 짚는다. +5. 개선 제안은 현재 코드의 실제 리스크가 있을 때만, tradeoff와 함께 제시한다. + +## Output + +- 먼저 전체 흐름을 짧게 요약한다. +- 그다음 핵심 파일별 역할을 설명한다. +- 마지막에 주의할 운영 리스크나 학습 포인트를 정리한다. diff --git a/.codex/skills/explain/agents/openai.yaml b/.codex/skills/explain/agents/openai.yaml new file mode 100644 index 00000000..5b93dce5 --- /dev/null +++ b/.codex/skills/explain/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Explain" + short_description: "OnO 코드 흐름 학습 설명" + default_prompt: "Use $explain to explain this OnO code path." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/feat/SKILL.md b/.codex/skills/feat/SKILL.md new file mode 100644 index 00000000..6aa943cf --- /dev/null +++ b/.codex/skills/feat/SKILL.md @@ -0,0 +1,31 @@ +--- +name: feat +description: Use for OnO feature implementation. Trigger when the user requests "feat" or plan-based implementation. If no argument is provided, inspect current git changes and continue with remaining implementation, cleanup, and validation work. +--- + +# OnO Feat + +## Workflow + +1. 기준 구현 명세서나 작업 설명을 확인한다. +2. 인자가 없으면 `git status`와 `git diff HEAD` 기준으로 현재 변경사항을 먼저 읽고, 이어서 구현해야 할 남은 작업이나 검증 대상을 파악한다. +3. 기준 문서가 불명확하고 현재 변경사항만으로 구현 의도가 확정되지 않으면 어떤 명세서를 사용할지 사용자에게 묻는다. +4. 명세서와 실제 코드가 다르면 실제 코드 기준으로 판단하고 차이를 설명한다. +5. 구현 범위를 명세서 또는 현재 변경사항의 의도에 맞춰 제한한다. 임의 확장, 과한 추상화, 무관한 리팩터링을 하지 않는다. +6. 백엔드 구현 시 API 계약, DTO, validation, 예외 처리, transaction, 권한, 성능, 로그, 데이터 정합성을 확인한다. +7. DB schema 변경은 migration/명시 SQL을 우선한다. H2 인메모리가 아닌 환경에 `ddl-auto=create` 또는 `create-drop`을 추가하지 않는다. +8. 기능 위험도에 맞춰 단위 테스트 또는 API 통합 테스트를 추가/갱신한다. +9. 가능한 범위에서 포맷팅, 정적 검사, 관련 테스트, 빌드를 실행한다. +10. 완료 직전에 아래 자가 반증 질문에 답하고, 필요한 경우 추가 수정 또는 리스크 보고를 한다. + - 내가 놓쳤을 가능성이 큰 것은? + - 이 변경이 실제 사용자에게 깨질 수 있는 경로는? + - 이 결론이 틀렸다면 어떤 조건 때문인가? + +## Completion Report + +- 변경한 내용 +- 사용자에게 미치는 영향 +- 실행한 검증과 결과 +- 자가 반증 결과 +- 남은 리스크 또는 확인 질문 +- 추천 커밋 단위와 커밋 메시지 diff --git a/.codex/skills/feat/agents/openai.yaml b/.codex/skills/feat/agents/openai.yaml new file mode 100644 index 00000000..12a1092c --- /dev/null +++ b/.codex/skills/feat/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Feat" + short_description: "OnO 기능 구현 워크플로우" + default_prompt: "Use $feat to implement this OnO feature." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/migrate/SKILL.md b/.codex/skills/migrate/SKILL.md new file mode 100644 index 00000000..2489cee5 --- /dev/null +++ b/.codex/skills/migrate/SKILL.md @@ -0,0 +1,36 @@ +--- +name: migrate +description: OnO DB schema 변경과 Flyway 마이그레이션 파일 작성/검토가 필요할 때 사용. 사용자가 "migrate <스키마 변경>"를 요청하면 기존 migration, entity, query 영향을 확인하고 안전한 SQL 초안을 만든다. +--- + +# OnO Migrate + +## Workflow + +1. 스키마 변경 목적, 대상 테이블, 사용자 데이터 영향 범위를 먼저 확인한다. +2. `src/main/resources/db/migration/`의 최신 Flyway 버전 번호와 기존 변경 이력을 확인한다. +3. 관련 entity, repository, query, DTO/API 계약을 추적한다. +4. 운영 DB 기준으로 되돌리기 어려운 작업인지 확인한다. +5. 마이그레이션은 가능한 안전한 순서로 설계한다. + - `ADD COLUMN NULL` + - 필요한 경우 backfill `UPDATE` + - 검증 후 `NOT NULL`, index, constraint 적용 +6. 대용량 테이블이면 lock, full scan, 배포 시간, rollback 가능성을 명시한다. +7. SQL 파일은 `V{N+1}__.sql` 형식으로 제안한다. +8. 파일 저장 전에는 사용자에게 최종 확인한다. 승인 없이 production 영향 가능성이 있는 migration 파일을 확정하지 않는다. + +## Guardrails + +- H2 인메모리 DB가 아닌 환경에 `ddl-auto=create` 또는 `create-drop`을 추가하지 않는다. +- `DROP`, `TRUNCATE`, column type 축소, `NOT NULL` 즉시 추가, 대량 backfill은 별도 위험으로 표시한다. +- rollback SQL 또는 대응 절차를 주석이나 보고에 포함한다. +- API 응답/요청 구조가 함께 바뀌면 Flutter 호환성과 기존 앱 버전 영향을 확인한다. + +## Output + +- 변경 목적과 대상 schema +- 기존 코드/쿼리 영향 +- migration SQL 초안 또는 생성 파일 +- rollback/대응 방법 +- dev/prod 적용 전 검증 방법 +- 배포 리스크와 확인 질문 diff --git a/.codex/skills/migrate/agents/openai.yaml b/.codex/skills/migrate/agents/openai.yaml new file mode 100644 index 00000000..475551cc --- /dev/null +++ b/.codex/skills/migrate/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Migrate" + short_description: "OnO DB 마이그레이션 안전 워크플로우" + default_prompt: "Use $migrate to design and review this OnO database migration." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/perf-audit/SKILL.md b/.codex/skills/perf-audit/SKILL.md new file mode 100644 index 00000000..fdfe2139 --- /dev/null +++ b/.codex/skills/perf-audit/SKILL.md @@ -0,0 +1,28 @@ +--- +name: perf-audit +description: OnO 백엔드 API 성능, 느린 쿼리, N+1, 풀스캔, 커넥션 풀, Flutter rebuild/jank/메모리 문제가 의심될 때 사용한다. +--- + +# OnO Performance Audit + +## Workflow + +1. 사용자가 체감하는 느린 흐름과 호출 경로를 먼저 좁힌다. +2. 백엔드는 controller/service/repository/query/transaction 경계를 따라간다. +3. Flutter는 화면 진입, 상태 변경, async 경합, rebuild 범위를 따라간다. +4. 운영 영향 기준으로 우선순위를 나눈다. + - 사용자 데이터 손상 또는 장애 가능성 + - API latency, DB 부하, 커넥션 고갈 가능성 + - UI jank, 중복 요청, 배터리/메모리 낭비 +5. 개선안은 관측 가능한 근거와 함께 제시하고, 과한 구조 변경은 피한다. + +## Backend Focus + +- N+1, unbounded query, pagination 누락, index 부재, 풀스캔 가능성 +- 트랜잭션 범위 과다/부족, lazy loading 도달 가능성 +- 외부 호출, FCM/메일 실발송, retry 부재 + +## Flutter Focus + +- 불필요한 rebuild, 큰 리스트의 비효율 렌더링, debounce/throttle 부재 +- loading/error/empty 상태에서 중복 요청 또는 화면 멈춤 diff --git a/.codex/skills/perf-audit/agents/openai.yaml b/.codex/skills/perf-audit/agents/openai.yaml new file mode 100644 index 00000000..f684fdfa --- /dev/null +++ b/.codex/skills/perf-audit/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Perf Audit" + short_description: "OnO 성능 리스크 점검" + default_prompt: "Use $perf-audit to inspect this OnO performance concern." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/perf/SKILL.md b/.codex/skills/perf/SKILL.md new file mode 100644 index 00000000..981b4538 --- /dev/null +++ b/.codex/skills/perf/SKILL.md @@ -0,0 +1,22 @@ +--- +name: perf +description: OnO 성능 병목 진단 요청에 사용. 사용자가 "perf" 명령을 요청하면 perf-audit 원칙에 따라 읽기 전용으로 호출 경로와 쿼리/렌더링 병목을 분석한다. 인자가 없으면 현재 git 변경사항이 건드린 흐름을 대상으로 한다. +--- + +# OnO Perf + +## Workflow + +1. 대상 기능, 엔드포인트, 패키지, 화면을 확인한다. 인자가 없으면 `git status`와 `git diff HEAD` 기준으로 현재 변경사항이 건드린 흐름을 대상으로 삼는다. +2. 백엔드는 controller/service/repository/query 흐름과 transaction 경계를 추적한다. +3. Flutter는 화면 진입, 상태 변경, async 경합, rebuild 범위를 추적한다. +4. N+1, fetch 전략, 풀스캔, pagination 누락, index 부재, 커넥션 풀 고갈, 데드락 가능성을 확인한다. +5. 읽기 전용으로 진단하고 코드는 수정하지 않는다. + +## Output + +- 🔴 즉시 개선 +- 🟠 배포 전 개선 권장 +- 🟡 백로그 + +각 개선안은 `파일:라인`, 원인, 개선 방법, 예상 효과를 포함한다. diff --git a/.codex/skills/perf/agents/openai.yaml b/.codex/skills/perf/agents/openai.yaml new file mode 100644 index 00000000..058eb015 --- /dev/null +++ b/.codex/skills/perf/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Perf" + short_description: "OnO 성능 병목 진단" + default_prompt: "Use $perf to audit this performance issue." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/plan/SKILL.md b/.codex/skills/plan/SKILL.md new file mode 100644 index 00000000..499e8e33 --- /dev/null +++ b/.codex/skills/plan/SKILL.md @@ -0,0 +1,29 @@ +--- +name: plan +description: OnO 백엔드 또는 Flutter 기능을 구현하기 전에 구현 명세서가 필요할 때 사용. 사용자가 "plan <기능 설명>"을 요청하면 코드를 수정하지 않고 계획서를 작성한다. 인자가 없으면 현재 git 변경사항을 대상으로 후속 구현/정리 계획서를 작성한다. +--- + +# OnO Plan + +## Workflow + +1. 인자가 없으면 `git status`와 `git diff HEAD` 기준으로 현재 변경사항의 목적과 영향 범위를 먼저 파악한다. +2. 요청 기능 또는 현재 변경사항의 사용자 시나리오와 운영 영향 범위를 정리한다. +3. 관련 코드, API 계약, DB schema, 인증/권한, 상태 흐름, 기존 문서를 확인한다. +4. 코드는 구현하지 않는다. 문서만 작성한다. +5. `/Users/ksm/programing/sw_maestro/OnO_BACKEND/backend/docs` 아래에 기능을 알아볼 수 있는 새 폴더를 만든다. +6. 구현 명세서에는 다음 항목을 포함한다. + - 기능 목적과 사용자 시나리오 + - 구현 범위와 제외 범위 + - 관련 화면, 라우팅, 상태 관리, API 영향 + - 데이터 흐름과 에러 처리 + - 구현 단계 + - 검증 방법 + - 배포 리스크와 확인 질문 +7. 이후 `feat` 요청에서 바로 구현할 수 있을 정도로 파일, 클래스, 테스트 방향을 구체화한다. + +## OnO 기준 + +- 실사용자 데이터, 학습 기록, 인증, 결제, 동기화, 알림, API 호환성을 최우선으로 본다. +- DB schema 변경이 필요하면 `ddl-auto=create` 또는 `create-drop`을 제안하지 않는다. +- 불확실한 API 계약이나 null 가능성은 추측하지 말고 확인 질문으로 남긴다. diff --git a/.codex/skills/plan/agents/openai.yaml b/.codex/skills/plan/agents/openai.yaml new file mode 100644 index 00000000..61dbc07f --- /dev/null +++ b/.codex/skills/plan/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Plan" + short_description: "OnO 기능 구현 명세서 작성" + default_prompt: "Use $plan to draft an OnO implementation plan." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/pr/SKILL.md b/.codex/skills/pr/SKILL.md new file mode 100644 index 00000000..7e69c2a5 --- /dev/null +++ b/.codex/skills/pr/SKILL.md @@ -0,0 +1,53 @@ +--- +name: pr +description: OnO 현재 브랜치의 개발 내역을 바탕으로 develop 브랜치에 merge 요청하는 GitHub PR을 직접 생성할 때 사용. 사용자가 "pr"을 요청하면 현재 브랜치에서 develop으로 PR을 생성한다. +--- + +# OnO PR + +## Workflow + +1. 현재 브랜치를 확인한다. 현재 브랜치가 `main` 또는 `develop`이면 PR을 생성하지 말고 작업 브랜치에서 실행해야 한다고 보고한다. +2. base branch는 항상 `develop`으로 고정한다. `main` branch를 base로 하는 PR은 절대 생성하지 않는다. `main` merge와 production 배포는 항상 사용자가 직접 담당한다. +3. `git status`로 미커밋 변경사항을 확인한다. 미커밋 변경사항이 있으면 PR을 생성하지 말고 먼저 커밋 또는 정리가 필요하다고 보고한다. +4. 현재 브랜치가 원격에 push되어 있는지 확인한다. 원격 브랜치가 없거나 미push 커밋이 있으면 `git push -u origin 현재브랜치`로 직접 push해 원격 브랜치를 만든 뒤 진행한다. +5. `develop..현재브랜치`의 `git log`와 `git diff`를 확인해 변경 범위를 파악한다. +6. GitHub에서 `AI-SIP/OnO_BACKEND`의 open issue를 먼저 조회해 현재 변경사항과 관련된 이슈가 있는지 확인한다. + - `mcp__github__.list_issues`를 사용한다. + - 브랜치명, 커밋 메시지, 변경 파일, 작업 키워드와 이슈 제목/본문을 비교한다. + - 실제로 이번 PR에서 해결되는 이슈가 있으면 PR 본문 `연관 이슈` 항목에 `close #이슈번호`를 포함한다. + - 단순 참고 이슈는 자동 종료되지 않도록 `close` 없이 참고 문장으로만 적는다. + - 관련 이슈가 없으면 `연관 이슈` 항목에 `없음`이라고 명시한다. +7. `.github/PULL_REQUEST_TEMPLATE.md`를 반드시 읽고 같은 구조로 PR 본문을 작성한다. +8. 수정된 파일, API/DB/인증/권한/알림/배포 영향 범위를 정리한다. +9. 테스트와 검증은 실행 여부, 명령, 결과를 구분해 적는다. +10. PR 제목은 브랜치명과 커밋 내역을 종합해 `[Feat]`, `[Fix]`, `[Refactor]`, `[Chore]`, `[Docs]`, `[Test]` 중 가장 적절한 타입으로 작성한다. +11. Labels는 변경 성격에 맞게 `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `bug` 중 적절한 후보를 고르되, `mcp__github__.get_label`로 존재 여부를 확인한 label만 적용한다. +12. `mcp__github__.create_pull_request`로 PR을 생성한다. + - `owner`: `AI-SIP` + - `repo`: `OnO_BACKEND` + - `base`: `develop` + - `head`: 현재 브랜치명 + - `title`: 작성한 PR 제목 + - `body`: 작성한 PR 본문 + - Reviewers는 지정하지 않는다. +13. PR 생성 후 생성된 PR 번호에 `mcp__github__.issue_write`를 `method=update`로 호출해 Assignees와 Labels를 설정한다. + - `owner`: `AI-SIP` + - `repo`: `OnO_BACKEND` + - `issue_number`: 생성된 PR 번호 + - `assignees`: `["KiSeungMin"]` + - `labels`: 존재 확인된 label 배열 +14. milestone, project, issue fields, type 등 나머지 항목은 지정하지 않는다. + +## Output + +- 생성된 PR 번호와 URL +- base/head branch +- 원격 브랜치 생성 또는 push 수행 여부 +- Assignee: `KiSeungMin` +- 적용한 label +- 연결한 issue와 `close #이슈번호` 포함 여부 +- `main`을 target으로 사용하지 않았다는 확인 +- 사용자 영향 +- 검증 결과 +- 배포 리스크와 rollback/대응 방법 diff --git a/.codex/skills/pr/agents/openai.yaml b/.codex/skills/pr/agents/openai.yaml new file mode 100644 index 00000000..94807585 --- /dev/null +++ b/.codex/skills/pr/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO PR" + short_description: "OnO PR 생성 워크플로우" + default_prompt: "Use $pr to push the branch if needed and create this OnO pull request." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/report/SKILL.md b/.codex/skills/report/SKILL.md new file mode 100644 index 00000000..a4a7a7c5 --- /dev/null +++ b/.codex/skills/report/SKILL.md @@ -0,0 +1,24 @@ +--- +name: report +description: 특정 커밋부터 현재까지의 OnO 작업 결과 보고서 또는 PR 초안을 작성할 때 사용. 인자가 없으면 현재 git 변경사항을 대상으로 변경 분석, 사용자 영향, 검증 결과, 배포 리스크를 markdown으로 정리한다. +--- + +# OnO Report + +## Workflow + +1. 기준 커밋 해시 또는 변경 범위를 확인한다. 인자가 없으면 현재 git 변경사항을 대상으로 한다. +2. `git log`, `git diff`, `git status`로 커밋된 변경과 미커밋 변경을 구분한다. +3. 수정된 파일, 레이어, API/DB/인증/알림 영향 범위를 정리한다. +4. Before/After가 의미 있는 변경은 표로 요약한다. +5. 테스트 코드는 세부 구현보다 검증 결과 중심으로 적는다. +6. 저장 위치가 지정되지 않았으면 사용자에게 확인한다. OnO 백엔드 문서는 기본적으로 `/Users/ksm/programing/sw_maestro/OnO_BACKEND/backend/docs` 아래에 둔다. + +## Output + +- 변경사항 요약 +- 사용자 영향 +- 검증 결과 +- 배포 리스크와 롤백/대응 방법 +- 미커밋 변경 반영 여부 +- 필요 시 PR 제목/본문 초안 diff --git a/.codex/skills/report/agents/openai.yaml b/.codex/skills/report/agents/openai.yaml new file mode 100644 index 00000000..b8845bd2 --- /dev/null +++ b/.codex/skills/report/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Report" + short_description: "OnO 변경 보고서 작성" + default_prompt: "Use $report to summarize this OnO change." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/test-writer/SKILL.md b/.codex/skills/test-writer/SKILL.md new file mode 100644 index 00000000..029751bc --- /dev/null +++ b/.codex/skills/test-writer/SKILL.md @@ -0,0 +1,23 @@ +--- +name: test-writer +description: OnO 백엔드 또는 Flutter 테스트를 새로 작성하거나 보강할 때 사용. Spring/JUnit/API 통합 테스트, Flutter widget/unit 테스트, 버그 재현 테스트가 필요한 요청에서 자동으로 사용한다. +--- + +# OnO Test Writer + +## Workflow + +1. 변경된 동작을 검증 가능한 성공/실패 조건으로 바꾼다. +2. 기존 테스트 구조, test profile, fixture, 인증 helper, DB 격리 방식을 먼저 확인한다. +3. 버그 수정이면 가능하면 실패하는 테스트를 먼저 추가하고 수정 후 통과시킨다. +4. 테스트 범위는 위험도에 맞춘다. + - 단위 테스트: validation, 예외 분기, 데이터 변환, 핵심 비즈니스 로직 + - API 통합 테스트: 요청/응답, 인증/권한, validation 실패, DB 반영, 에러 응답 + - Flutter 테스트: null/empty/error/loading 상태와 핵심 위젯 렌더링 +5. 테스트가 어려운 구조이면 이유와 대체 검증을 명확히 남긴다. + +## OnO 체크 + +- 사용자별 데이터 격리와 권한 실패 케이스를 우선 확인한다. +- 검색, 오답노트, 스터디그룹, 결제, 알림, 동기화 흐름은 성공 케이스만으로 끝내지 않는다. +- MySQL/PostgreSQL 기반 환경에서 테스트 편의를 위해 `ddl-auto=create` 또는 `create-drop`을 추가하지 않는다. diff --git a/.codex/skills/test-writer/agents/openai.yaml b/.codex/skills/test-writer/agents/openai.yaml new file mode 100644 index 00000000..704546fb --- /dev/null +++ b/.codex/skills/test-writer/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Test Writer" + short_description: "OnO 테스트 작성과 보강" + default_prompt: "Use $test-writer to add focused tests for this OnO change." + +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/test/SKILL.md b/.codex/skills/test/SKILL.md new file mode 100644 index 00000000..cffc379e --- /dev/null +++ b/.codex/skills/test/SKILL.md @@ -0,0 +1,21 @@ +--- +name: test +description: OnO 테스트 작성 및 실행 요청에 사용. 사용자가 "test" 명령을 요청하면 test-writer 원칙에 따라 기존 테스트 구조를 확인하고 필요한 테스트를 작성/실행한다. 인자가 없으면 현재 git 변경사항을 대상으로 한다. +--- + +# OnO Test + +## Workflow + +1. 대상이 없으면 `git status`와 `git diff HEAD` 기준의 현재 변경사항으로 테스트 대상을 좁힌다. +2. 기존 테스트 구조, test profile, fixture, 인증 helper, DB 격리 방식을 확인한다. +3. 필요한 테스트를 작성하거나 보강한다. +4. 사용자 격리, 권한 실패, validation 실패, null/empty, 예외 케이스를 위험도에 맞춰 포함한다. +5. 외부 연동(FCM, 메일 등)은 mock 또는 fake로 검증한다. +6. 관련 테스트만 우선 실행하고, 실패하면 원인 파악 후 수정한다. + +## Guardrails + +- MySQL/PostgreSQL 환경에 `ddl-auto=create` 또는 `create-drop`을 추가하지 않는다. +- 기존 저품질 테스트를 그대로 복붙하지 않는다. +- 테스트 실행이 어렵다면 이유와 대체 검증을 보고한다. diff --git a/.codex/skills/test/agents/openai.yaml b/.codex/skills/test/agents/openai.yaml new file mode 100644 index 00000000..96e72414 --- /dev/null +++ b/.codex/skills/test/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "OnO Test" + short_description: "OnO 테스트 작성 실행" + default_prompt: "Use $test to write and run focused tests." + +policy: + allow_implicit_invocation: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0bc873a4..50e06d7b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,4 +6,37 @@ > 이번 PR에서 작업한 내용을 간략히 설명해주세요 +## 👤 사용자 영향 + +- + +## 🔌 API 호환성 + +- [ ] 요청/응답 DTO 변경 없음 +- [ ] 기존 Flutter 앱 버전과 호환 확인 +- [ ] breaking change 있음 + +## 🗄️ DB migration + +- [ ] 없음 +- [ ] 있음: migration 파일과 기존 데이터 호환성 확인 + +## 🔐 인증/권한 + +- [ ] 영향 없음 +- [ ] 사용자별 데이터 소유권 검증 확인 +- [ ] 관리자/특수 권한 영향 확인 + +## ✅ 검증 결과 + +- + +## 🚀 배포 리스크 + +- + +## ↩️ 롤백/대응 방법 + +- + ### 스크린샷 (선택) diff --git a/.github/workflows/agent-config-lint.yml b/.github/workflows/agent-config-lint.yml new file mode 100644 index 00000000..27094e4b --- /dev/null +++ b/.github/workflows/agent-config-lint.yml @@ -0,0 +1,36 @@ +name: Agent Config Lint + +on: + pull_request: + paths: + - "AGENTS.md" + - "CLAUDE.md" + - ".claude/**" + - ".codex/**" + - ".github/PULL_REQUEST_TEMPLATE.md" + - ".github/workflows/agent-config-lint.yml" + - ".gitignore" + push: + branches: ["main", "develop"] + paths: + - "AGENTS.md" + - "CLAUDE.md" + - ".claude/**" + - ".codex/**" + - ".github/PULL_REQUEST_TEMPLATE.md" + - ".github/workflows/agent-config-lint.yml" + - ".gitignore" + +permissions: + contents: read + +jobs: + agent-config-lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run agent config lint + run: bash .codex/scripts/agent-config-lint.sh diff --git a/.github/workflows/close-issues-on-develop-merge.yml b/.github/workflows/close-issues-on-develop-merge.yml new file mode 100644 index 00000000..6d3c908f --- /dev/null +++ b/.github/workflows/close-issues-on-develop-merge.yml @@ -0,0 +1,51 @@ +name: Close Issues on Develop Merge + +on: + pull_request: + types: [closed] + branches: + - develop + +permissions: + issues: write + +jobs: + close-related-issues: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Close referenced issues + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const body = context.payload.pull_request.body || ''; + const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi; + const issueNumbers = []; + let match; + + while ((match = pattern.exec(body)) !== null) { + issueNumbers.push(parseInt(match[1], 10)); + } + + const unique = [...new Set(issueNumbers)]; + if (unique.length === 0) { + console.log('연관 이슈 없음 — 종료'); + return; + } + + for (const number of unique) { + try { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + state: 'closed', + state_reason: 'completed', + }); + console.log(`이슈 #${number} 닫힘`); + } catch (e) { + console.log(`이슈 #${number} 처리 실패: ${e.message}`); + } + } \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8dd0c926..64e66cda 100644 --- a/.gitignore +++ b/.gitignore @@ -68,5 +68,8 @@ monitoring/README.md .DS_Store ### Local agent guidance -/AGENTS.md .claude.md + +### Claude Code local settings (machine-specific, not team-shared) +.claude/settings.local.json +.claude/hooks/__pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9e47c17f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,239 @@ +OnO 작업 에이전트 지침 + +이 문서는 OnO 프로젝트에서 AI 에이전트가 작업할 때 따라야 하는 공통 규칙이다. 사용자는 백엔드 개발에 더 익숙하며, 프론트엔드와 문서화 작업에서 AI 에이전트가 일관된 기준으로 판단하고 산출물을 남기기를 기대한다. + +## 0. 사용자와 서비스 맥락 + +사용자는 OnO 서비스를 운영하는 1인 개발자다. Flutter 애플리케이션 개발도 직접 하고 있으며, 동시에 백엔드 개발자를 희망하는 취업 준비생이다. + +- OnO는 실사용자가 있는 실제 운영 서비스다. +- 배포된 변경사항은 사용자 환경에 바로 영향을 줄 수 있다. +- 모든 판단의 1순위는 실사용자에게 끼칠 영향이다. +- 기능 구현, 버그 수정, 리팩터링, 설정 변경 모두 사용자 데이터, 학습 기록, 인증, 결제, 동기화, 알림, API 호환성에 미칠 영향을 먼저 고려한다. +- 단순히 코드를 작성하는 데서 끝내지 말고, 운영 중인 서비스에 적용해도 안전한지 확인한다. +- 사용자에게 영향을 줄 수 있는 변경은 영향 범위, 배포 리스크, 롤백 또는 대응 방법을 함께 설명한다. + +## 0.1 백엔드 작업 기준 + +사용자는 백엔드 개발자로 성장하는 것을 중요하게 생각한다. 백엔드 작업을 할 때는 단순 구현뿐 아니라, 백엔드 개발자 관점에서의 참고사항, 주의사항, 더 나은 방법을 함께 제안한다. + +- API 계약, DTO, validation, 예외 처리, 트랜잭션 경계, 동시성, 보안, 성능, 로그, 모니터링, 데이터 정합성을 신중히 검토한다. +- 임시로 동작만 맞추는 방식보다 운영 환경에서 설명 가능한 구조를 우선한다. +- 다만 과한 추상화나 미래 확장용 구조는 만들지 않는다. +- 변경이 사용자 데이터나 DB schema에 영향을 주면 migration, 기존 데이터 호환성, 장애 시 복구 가능성을 확인한다. +- 인증, 권한, 개인정보, 결제, 학습 기록처럼 민감한 흐름은 더 보수적으로 검토한다. +- 더 나은 방법이 있으면 이유와 tradeoff를 짧게 설명하고, 필요한 경우 현재 요청 범위 안에서 적용 가능한 최소 변경을 제안한다. +- 백엔드 개발자로서의 참고사항이나 더 나은 방법은 중요한 설계 판단이 있을 때만 설명한다. + +## 0.1.1 DB schema 안전 규칙 + +운영 서비스의 데이터 보존이 최우선이다. DB schema 자동 생성 설정은 매우 보수적으로 다룬다. + +- H2 인메모리 DB가 아닌 환경에서는 `create`, `create-drop`을 절대 사용하지 않는다. +- 로컬, 개발, 테스트, 운영 DB가 MySQL/PostgreSQL 등 실제 파일 또는 서버 기반 DB라면 `create`, `create-drop` 설정을 추가하거나 변경하지 않는다. +- Hibernate/JPA `ddl-auto` 변경은 기존 데이터 삭제 가능성을 먼저 검토한다. +- schema 변경이 필요하면 migration 도구나 명시적인 SQL 변경 이력을 우선 고려한다. +- 테스트에서 `create` 또는 `create-drop`이 필요하다면 H2 인메모리 DB인지 먼저 확인하고, 설정 파일명과 active profile을 함께 검토한다. +- DB 설정 변경 후에는 어떤 profile에 적용되는지, 운영 환경에 전파될 가능성이 없는지 반드시 확인한다. + +## 0.2 테스트 기준 + +백엔드에서는 테스트 코드를 중요하게 본다. 현재 테스트 코드가 충분하지 않더라도, 앞으로 구현하는 기능과 수정 사항은 가능한 한 테스트를 함께 작성하고 그 테스트로 검증한다. + +- 백엔드 기능 구현 시 관련 테스트를 함께 추가하거나 갱신한다. +- 백엔드 테스트는 단위 테스트와 전체 API 통합 테스트를 우선한다. +- 단위 테스트는 핵심 비즈니스 로직, validation, 예외 분기, 데이터 변환을 빠르게 검증하는 데 사용한다. +- 전체 API 통합 테스트는 실제 요청/응답 흐름, 인증/권한, validation 실패, DB 반영, 에러 응답 형식을 검증하는 데 사용한다. +- 버그 수정은 가능하면 실패하는 테스트를 먼저 만들고, 수정 후 통과시키는 흐름을 따른다. +- 테스트가 어려운 구조라면 이유를 설명하고, 최소한 어떤 검증을 대신 수행했는지 남긴다. +- 테스트 범위는 변경 위험도에 맞춘다. +- API 동작 변경은 성공 케이스뿐 아니라 validation 실패, 권한 실패, null/empty 입력, 예외 케이스도 가능한 범위에서 확인한다. +- 테스트가 부실한 영역을 발견하면 임의로 대규모 개선하지 말고, 필요한 개선 방향을 별도 리스크로 언급한다. + +## 1. 기본 원칙 + +**추측하지 말고, 불확실하면 먼저 드러낸다.** + +- 요구사항이 여러 방식으로 해석될 수 있으면 선택지를 짧게 설명하고 확인한다. +- 구현 전에 관련 문서, 코드 흐름, 상태 관리, 라우팅, API 연동 방식을 먼저 확인한다. +- 단순한 해결책이 있으면 먼저 제안한다. +- 요청받은 범위를 넘어서는 기능, 추상화, 설정화, 리팩터링을 임의로 추가하지 않는다. +- API 계약, 응답 필드, null 가능성, 에러 응답 형태가 불명확하면 임의로 단정하지 않는다. + +## 2. 변경 범위 관리 + +**필요한 파일과 필요한 줄만 수정한다.** + +- 요청과 무관한 코드, 주석, 포맷팅, 파일 이동, 이름 변경을 하지 않는다. +- 주변 코드 스타일, 파일 구조, 네이밍, 상태 관리 패턴을 따른다. +- 내 변경으로 생긴 미사용 import, 변수, 함수만 정리한다. +- 기존에 있던 죽은 코드나 이상한 구조는 임의로 삭제하지 말고 필요하면 별도로 언급한다. +- 같은 파일에 사용자의 다른 변경이 있어도 되돌리지 않는다. +- 모든 변경 라인은 사용자의 요청과 직접 연결되어야 한다. + +## 3. 검증 원칙 + +**작업 목표를 검증 가능한 기준으로 바꾸고, 가능한 범위에서 끝까지 확인한다.** + +- 버그 수정은 재현 조건과 수정 후 확인 방법을 함께 정리한다. +- 기능 구현은 사용자 흐름, 빈 상태, 에러 상태, 로딩 상태, 반응형 영향을 함께 확인한다. +- 백엔드 작업은 관련 단위 테스트, 통합 테스트, 빌드 또는 정적 검사를 우선 수행한다. +- 검증을 실행하지 못했다면 이유와 남은 리스크를 명확히 말한다. +- 위험도가 높은 변경은 diff 검토와 실행 검증을 분리해 2중으로 확인한다. + +## 4. 문서 위치 + +백엔드 기능 문서와 PR 초안은 아래 경로를 기준으로 작성한다. + +```text +/Users/ksm/programing/sw_maestro/OnO_BACKEND/backend/docs +``` + +- 기능별로 새 폴더를 만든다. +- 폴더명은 기능을 알아볼 수 있게 작성한다. +- 같은 기능의 구현 명세서와 PR 초안은 같은 폴더에 둔다. +- 다른 에이전트가 이어서 작업할 수 있도록 배경, 범위, 결정 사항, 검증 방법을 문서에 남긴다. + +## 5. 커스텀 명령어 + +아래 명령어는 실제 CLI 명령이 아니라, 사용자가 대화에서 입력하는 작업 모드다. 에이전트는 명령어의 의도를 해석해 필요한 파일 확인, 문서 작성, 구현, 검토를 수행한다. + +### `plan <기능 설명>` + +해당 기능을 어떻게 구현할지에 관한 개요 및 구현 명세서를 markdown으로 작성한다. + +- 코드를 구현하지 않는다. +- 관련 기존 문서와 코드를 먼저 확인한다. +- `/Users/ksm/programing/sw_maestro/OnO_BACKEND/backend/docs` 안에 해당 기능과 관련된 새 폴더를 만든다. +- 새 폴더 안에 markdown 구현 명세서를 작성한다. +- 문서에는 최소한 다음 내용을 포함한다. + - 기능 목적과 사용자 시나리오 + - 구현 범위와 제외 범위 + - 관련 화면, 라우팅, 상태 관리, API 영향 + - 데이터 흐름과 에러 처리 + + - 구현 단계 + - 검증 방법 + - 배포 리스크와 확인이 필요한 질문 +- 이후 `feat` 명령에서 바로 구현을 시작할 수 있을 만큼 구체적으로 작성한다. + +### `feat` + +`plan`으로 작성한 구현 명세서를 바탕으로 기능을 구현한다. + +- 먼저 어떤 구현 명세서를 기준으로 작업할지 확인한다. +- 명세서가 명확하지 않으면 사용자에게 물어본다. +- 명세서의 범위를 벗어나는 기능을 임의로 추가하지 않는다. +- 명세서와 실제 코드가 다르면 실제 코드 기준으로 판단하되, 차이를 사용자에게 설명한다. +- 구현 후 사용자 영향, 반응형 처리, 배포 리스크를 점검한다. +- 가능한 범위에서 포맷팅, 정적 검사, 관련 테스트를 수행한다. +- 완료 후 추천 커밋 단위와 커밋 메시지를 제안한다. +- 필요에 따라 멀티 에이전트를 활용한다. + +프론트엔드와 백엔드 모두 커밋 메시지는 아래 형식으로 통일한다. + +```text +[Feat] 구현 타이틀 - 구현 상세1 구현 상세2 +``` + +예시: + +```text +[Feat] 학습 달력 주간 뷰 추가 - 태블릿 레이아웃 대응 빈 상태 처리 +[Fix] 오답노트 PDF 내보내기 오류 수정 - null 응답 방어 로딩 상태 개선 +``` + +### `pr` + +현재 브랜치의 개발 내역을 바탕으로 `AI-SIP/OnO_BACKEND` GitHub Repository에 `develop` 브랜치로 merge 요청하는 Pull Request를 직접 생성한다. + +- `codex_apps` GitHub 도구가 아니라 `mcp__github__` MCP 서버의 `create_pull_request` 도구를 사용한다. +- Repository는 항상 `owner=AI-SIP`, `repo=OnO_BACKEND`를 사용한다. +- base branch는 항상 `develop`으로 지정한다. +- `main` branch를 base로 하는 PR은 절대 생성하지 않는다. `main` merge와 production 배포는 항상 사용자가 직접 담당한다. +- 현재 브랜치가 `main` 또는 `develop`이면 PR을 생성하지 말고, 작업 브랜치에서 실행해야 한다고 보고한다. +- 현재 브랜치 이름과 원격 브랜치 존재 여부를 확인한다. +- 작업 트리에 아직 커밋되지 않은 변경사항이 있으면 PR을 생성하지 말고, 먼저 커밋 또는 정리가 필요하다고 보고한다. +- 현재 브랜치가 원격에 없거나 미push 커밋이 있으면 `git push -u origin 현재브랜치`로 직접 push해 원격 브랜치를 만든 뒤 PR을 생성한다. +- `develop..현재브랜치`의 커밋 로그와 diff를 확인해 PR 제목과 본문을 작성한다. +- PR 생성 전에 GitHub에서 open issue를 검색해 현재 변경사항과 관련된 이슈가 있는지 확인한다. +- 관련 이슈가 있으면 PR 본문 `연관 이슈` 항목에 `close #이슈번호`를 포함해 merge 시 자동 종료되도록 작성한다. +- 관련 이슈가 없으면 `연관 이슈` 항목에는 관련 이슈 없음이라고 명시한다. +- 아래 PR 템플릿을 반드시 확인한다. + +```text +/Users/ksm/programing/sw_maestro/OnO_BACKEND/backend/.github/PULL_REQUEST_TEMPLATE.md +``` + +- PR 제목과 본문은 템플릿 양식에 맞춰 작성하고, PR markdown 파일은 별도로 만들지 않는다. +- PR 본문에는 변경사항 요약, 사용자 영향, 검증 결과, 배포 리스크를 포함한다. +- Reviewers는 지정하지 않는다. +- PR 생성 후 생성된 PR 번호에 `mcp__github__` MCP 서버의 `issue_write` 도구를 `method=update`로 호출해 Assignees와 Labels를 설정한다. +- Assignees는 항상 `KiSeungMin`으로 지정한다. +- Labels는 변경 성격에 맞게 `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `bug` 중 적절한 후보를 고르되, `get_label`로 존재 여부를 확인한 label만 적용한다. +- milestone, project, issue fields, type 등 나머지 항목은 지정하지 않는다. +- PR 생성 후에는 PR 번호, URL, base/head branch, assignee, 적용한 label, main을 target으로 사용하지 않았다는 점을 간결하게 보고한다. + +### `/issue <내용 또는 파일 경로>` + +사용자가 자유롭게 작성한 내용이나 파일 내용을 바탕으로 `AI-SIP/OnO_BACKEND` GitHub Repository에 이슈를 생성한다. + +- `codex_apps` GitHub 도구가 아니라 `mcp__github__` MCP 서버의 `issue_write` 도구를 사용한다. +- Repository는 항상 `owner=AI-SIP`, `repo=OnO_BACKEND`를 사용한다. +- Assignees는 항상 `KiSeungMin`으로 지정한다. +- 사용자가 파일 경로를 전달하면 먼저 해당 파일을 읽고, 파일 내용을 이슈 원문으로 사용한다. +- 사용자가 직접 내용을 입력하면 그 내용을 이슈 원문으로 사용한다. +- 원문이 부족해도 가능한 범위에서 이슈를 생성하되, 운영 영향이나 재현 조건처럼 필수 판단에 필요한 정보가 없으면 이슈 본문에 "확인 필요"로 명시한다. +- 아래 이슈 템플릿을 반드시 확인하고, 입력 성격에 맞는 템플릿을 선택해 본문을 채운다. + +```text +/Users/ksm/programing/sw_maestro/OnO_BACKEND/backend/.github/ISSUE_TEMPLATE/bug-report-template.md +/Users/ksm/programing/sw_maestro/OnO_BACKEND/backend/.github/ISSUE_TEMPLATE/common-issue-template.md +``` + +- 버그, 오류, 예외, 장애, 잘못된 동작, 회귀 이슈는 `bug-report-template.md` 형식을 사용한다. + - 제목은 `[bug] ...` 형식으로 작성한다. + - label은 `bug`를 포함한다. + - 본문에는 버그 설명, 발생 상황, 예상 결과를 템플릿 항목에 맞춰 정리한다. +- 기능 추가, 개선, 리팩터링, 문서, 설정, 운영 작업은 `common-issue-template.md` 형식을 사용한다. + - 제목은 `[feat]`, `[fix]`, `[refactor]`, `[chore]`, `[docs]`, `[test]` 중 가장 적절한 타입을 골라 작성한다. + - label은 선택한 타입과 이슈 성격에 맞게 적용하되, 존재 여부가 불확실한 label은 무리하게 붙이지 않는다. + - 본문에는 Description, TODO, ETC를 템플릿 항목에 맞춰 정리한다. +- GitHub issue type 기능이 사용 가능하면 입력 성격에 맞는 type을 설정한다. 유효한 type을 확인할 수 없거나 Repository가 issue type을 지원하지 않으면 `type` 필드는 생략한다. +- 이슈 생성 후에는 이슈 번호, URL, 적용한 assignee, label, type을 사용자에게 간결하게 보고한다. + +### `check <커밋 번호>` + +해당 커밋부터 현재까지의 변경사항을 모두 분석한 뒤, 위험한 사항이나 에러 가능성이 없는지 검증한다. + +- `<커밋 번호>`가 있으면 해당 커밋부터 현재까지의 변경사항을 확인한다. +- 커밋 번호가 없으면 현재 작업 중인 변경사항만 검토한다. +- 프로젝트 전체를 대충 훑지 말고, 변경된 파일과 영향받는 사용자 흐름을 기준으로 검토한다. +- 검사는 반드시 2중으로 수행한다. +- 1차 검사는 diff 기반으로 수행한다. + - 변경된 코드의 의도와 실제 동작이 맞는지 확인한다. + - API 연동, 상태 관리, null 처리, 에러 처리, 로딩 상태, 데이터 손실 가능성을 확인한다. + - UI 변경이 있으면 폰과 태블릿에서 overflow, 잘림, 스크롤 불가 가능성을 확인한다. +- 2차 검사는 실행 가능한 검증으로 수행한다. + - 가능한 경우 포맷팅 검사, 정적 분석, 관련 테스트, 빌드 영향 파일 확인을 진행한다. + - 실행 검증이 어렵다면 구조 검토로 대체하고, 대체한 이유를 적는다. +- 검토 결과는 위험도 순서로 정리한다. +- 문제를 발견하면 파일과 라인 근거를 포함한다. +- 발견한 문제가 없더라도 남은 리스크와 실행하지 못한 검증을 명확히 적는다. + +### `commit` + +- 현재 변경사항에 대해 커밋 단위 및 커밋 메시지를 양식에 맞게 정리한 뒤 직접 커밋까지 수행한다. +- 커밋 전 `git status`와 `git diff HEAD`로 변경 범위를 확인하고, 사용자 변경과 이번 작업 변경을 구분한다. +- 관련 없는 변경이 섞여 있으면 하나의 커밋에 포함하지 않는다. 필요한 경우 논리 단위별로 `git add` 대상을 나눠 커밋한다. +- 커밋 메시지는 OnO 형식을 따른다. + +## 6. 작업 보고 + +작업 완료 후에는 다음 내용을 간결하게 정리한다. + +- 변경한 내용 +- 사용자에게 미치는 영향 +- 실행한 검증과 결과 +- 남은 리스크 또는 확인이 필요한 부분 +- 추천 커밋 단위와 커밋 메시지 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..ffaf10d4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# OnO 백엔드 — CLAUDE.md + +## Operating constraints + +### 자유롭게 해도 되는 것 +- 읽기 전용 조사: 파일 읽기, 검색, `git status/diff/log/show`, 영향 범위 분석. +- 로컬 검증: 테스트, 빌드, 정적 검사, dev 환경 확인. +- 브랜치 확인, 커밋 메시지 정리, `/commit` 요청에 따른 실제 커밋 수행. push는 `/pr`에서 원격 브랜치 생성 또는 미push 커밋 반영이 필요할 때 수행한다. merge는 사용자가 명시적으로 요청한 경우에만 수행한다. + +### 실사용자 서비스 — 프로덕션 안전 우선 +- **라이브 서비스를 깨지 말 것.** 되돌리기 어렵거나 사용자에게 영향 가는 변경(배포·스키마 변경·데이터 마이그레이션)은 실행 전 영향 범위를 설명하고 확인받는다. +- **dev 서버에서 먼저 검증 후 prod 반영.** 프로덕션 MySQL 파괴·마이그레이션 신중. +- **FCM = 실사용자 푸시 발송.** 발송 경로를 수정하거나 실행하기 전 반드시 경고하고 확인받는다. +- 프로덕션 DB에 대한 삭제/대량 수정/truncate는 명시적 확인 없이 실행하지 않는다. +- Docker 배포, 인프라 설정 변경, 외부 발송, 되돌리기 어려운 작업은 실행 전 영향 범위와 rollback 방법을 설명하고 확인받는다. + +### 작업 방식 +- 커밋 메시지 형식: + ``` + [Feat] 한 줄 요약 + - 상세 1 + - 상세 2 + ``` + 태그: `[Feat]` / `[Fix]` / `[Refactor]` / `[Chore]` / `[Test]` / `[Docs]` / `[Perf]` +- 장시간 작업은 중간 진행 공유. +- **응답 언어: 한국어.** 주석·커밋도 한국어. +- 근거는 반드시 `파일:라인` 형식으로 명시. 추정 금지. + +## 빌드 / 테스트 +```bash +./gradlew compileJava # 엔티티 변경 후 컴파일 확인 +./gradlew test --tests "패키지.클래스.메서드" # 특정 테스트 실행 +./gradlew build # 전체 빌드 +``` + +## 프로젝트 구조 + +**스택**: Spring Boot + MySQL + Docker, dev/prod 환경 분리 +**루트 패키지**: `com.aisip.OnO.backend` + +| 패키지 | 도메인 | +|---|---| +| `auth` | 인증 (OAuth, JWT) | +| `user` | 사용자 계정·프로필 | +| `problem` | 오답 문제 등록·조회·수정·삭제 | +| `practicenote` | 복습 기록 | +| `folder` | 폴더 (문제 분류) | +| `tag` | 태그 | +| `mission` | 미션·보상 | +| `problemsolve` | 문제 풀이 기록 | +| `learningcalendar` | 학습 캘린더 | +| `learningreport` | 학습 리포트 | +| `admin` | 관리자 기능 | +| `common` | 공통 유틸·예외·응답 | +| `config` | 설정 (Security, JPA 등) | +| `util` | 유틸리티 | + +**핵심 불변식**: 모든 데이터 접근은 `userId` 기준 소유권 검증 필수 — 사용자는 자신의 오답노트 데이터만 접근 가능. + +## 작업 파이프라인 + +``` +일반 기능: analysis → plan → feat → test → check → commit → report +성능 작업: perf(진단) → plan → feat → test → check → commit → report +``` +- 단계 경계에서 사람이 검토 후 다음으로 진행 (자동 연쇄 X). +- `analysis`는 요구 명확하면 생략 가능. `explain`은 아무 때나. +- 검토 강도: plan 후 🔴최고 / feat 후 🟠 / check 결과 🟡 / report 🟢. diff --git a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java index 7643715b..d162df1f 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java +++ b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java @@ -106,6 +106,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/folders/**").hasAnyRole("GUEST", "MEMBER", "ADMIN") .requestMatchers("/api/fileUpload/**").hasAnyRole("GUEST", "MEMBER", "ADMIN") .requestMatchers("/api/practiceNotes/**").hasAnyRole("GUEST", "MEMBER", "ADMIN") + .requestMatchers("/api/study-room/**", "/api/study-rooms/**").hasAnyRole("GUEST", "MEMBER", "ADMIN") .anyRequest().authenticated() ) .formLogin(formLogin -> formLogin diff --git a/src/main/java/com/aisip/OnO/backend/common/config/JpaBatchConfig.java b/src/main/java/com/aisip/OnO/backend/common/config/JpaBatchConfig.java new file mode 100644 index 00000000..382100ab --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/config/JpaBatchConfig.java @@ -0,0 +1,21 @@ +package com.aisip.OnO.backend.common.config; + +import org.hibernate.cfg.AvailableSettings; +import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Map; + +@Configuration +public class JpaBatchConfig { + + @Bean + public HibernatePropertiesCustomizer hibernateBatchCustomizer() { + return hibernateProperties -> hibernateProperties.putAll(Map.of( + AvailableSettings.STATEMENT_BATCH_SIZE, "50", + AvailableSettings.ORDER_INSERTS, "true", + AvailableSettings.ORDER_UPDATES, "true" + )); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/common/config/SentryConfig.java b/src/main/java/com/aisip/OnO/backend/common/config/SentryConfig.java new file mode 100644 index 00000000..e8033865 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/config/SentryConfig.java @@ -0,0 +1,23 @@ +package com.aisip.OnO.backend.common.config; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import io.sentry.SentryOptions; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SentryConfig { + + @Bean + public SentryOptions.BeforeSendCallback sentryBeforeSendCallback() { + return (event, hint) -> { + Throwable throwable = event.getThrowable(); + if (throwable instanceof ApplicationException appEx) { + if (appEx.getErrorCase().getHttpStatusCode() < 500) { + return null; + } + } + return event; + }; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiErrorCase.java b/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiErrorCase.java new file mode 100644 index 00000000..03e68324 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiErrorCase.java @@ -0,0 +1,16 @@ +package com.aisip.OnO.backend.common.emoji; + +import com.aisip.OnO.backend.common.exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CustomEmojiErrorCase implements ErrorCase { + + INVALID_EMOJI_KEY(400, 11001, "지원하지 않는 이모지입니다."); + + private final Integer httpStatusCode; + private final Integer errorCode; + private final String message; +} diff --git a/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidator.java b/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidator.java new file mode 100644 index 00000000..c858f376 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidator.java @@ -0,0 +1,98 @@ +package com.aisip.OnO.backend.common.emoji; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Component +public class CustomEmojiValidator { + + private static final Set ALLOWED_KEYS = Set.of( + "angry_with_flames", + "crying_in_rain", + "confused_question", + "cool_sunglasses", + "happy_tears", + "excited_happy", + "lol_laughing_tears", + "cheek_to_cheek", + "dizzy_spiral_eyes2", + "stressed_bomb", + "shy_clasped_hands", + "shy_wink_heart", + "in_love_heart", + "scared_dread", + "star_eyes_excited", + "frustrated_studying", + "holding_pen", + "reading_with_glasses", + "reading_tablet", + "studying_together", + "studying_with_lamp", + "idea_lightbulb", + "got_100_score", + "success_checkmark", + "writing_wink", + "working_laptop", + "birthday_cake", + "celebrating_confetti", + "cheering_megaphone", + "excited_with_gift", + "fired_up_sparkle_eyes", + "superhero_cape", + "gold_medal", + "holding_flower", + "holding_heart", + "lifting_weights", + "party_celebration", + "praying_grateful", + "sprout_growth", + "trophy_celebration", + "thumbs_up_happy", + "thumbs_up_wink", + "thank_you_sign", + "winking_fist", + "hi_greeting", + "waving_hello", + "cheers_beer", + "cuddle_love", + "texting_heart", + "puzzle_teamwork", + "peeking_wall", + "blue_hoodie", + "sleeping_blanket", + "cozy_blanket", + "cooking_chef", + "drinking_coffee", + "sleeping_with_star", + "eating_ice_cream", + "eating_skewer", + "playing_game_controller", + "hiking_backpack", + "listening_headphones", + "morning_coffee", + "shopping_bags", + "singing_happy", + "taking_bath", + "taking_photo", + "umbrella_rain", + "wearing_scarf" + ); + + public void validate(String emojiKey) { + if (!isAllowed(emojiKey)) { + throw new ApplicationException(CustomEmojiErrorCase.INVALID_EMOJI_KEY); + } + } + + public void validateNullable(String emojiKey) { + if (emojiKey != null) { + validate(emojiKey); + } + } + + public boolean isAllowed(String emojiKey) { + return emojiKey != null && ALLOWED_KEYS.contains(emojiKey); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java index 68069418..97eb3ff0 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java @@ -6,6 +6,7 @@ import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -33,10 +34,14 @@ public class RabbitMQConfig { public static final String FCM_NOTIFICATION_QUEUE = "fcm.notification.queue"; public static final String FCM_NOTIFICATION_DLQ = "fcm.notification.dlq"; + public static final String DISCORD_WEBHOOK_QUEUE = "discord.webhook.queue"; + public static final String DISCORD_WEBHOOK_DLQ = "discord.webhook.dlq"; + // ==================== Routing Key 정의 ==================== public static final String S3_DELETE_ROUTING_KEY = "s3.delete"; public static final String GPT_ANALYSIS_ROUTING_KEY = "gpt.analysis"; public static final String FCM_NOTIFICATION_ROUTING_KEY = "fcm.notification"; + public static final String DISCORD_WEBHOOK_ROUTING_KEY = "discord.webhook"; /** * JSON 메시지 컨버터 (객체를 JSON으로 직렬화/역직렬화) @@ -61,10 +66,12 @@ public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { */ @Bean public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory( - ConnectionFactory connectionFactory) { + ConnectionFactory connectionFactory, + @Value("${spring.rabbitmq.listener.simple.auto-startup:true}") boolean autoStartup) { SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); factory.setConnectionFactory(connectionFactory); factory.setMessageConverter(messageConverter()); + factory.setAutoStartup(autoStartup); factory.setConcurrentConsumers(3); // 동시 처리 스레드 수 factory.setMaxConcurrentConsumers(10); // 최대 동시 처리 스레드 수 factory.setPrefetchCount(1); // 한 번에 가져올 메시지 수 @@ -199,4 +206,33 @@ public Binding fcmNotificationBinding() { .to(notificationExchange()) .with(FCM_NOTIFICATION_ROUTING_KEY); } -} \ No newline at end of file + + // ==================== Discord Webhook Queue ==================== + + /** + * Discord Webhook Queue + * - DLQ 설정 포함 + * - TTL: 5분 (300,000ms) + */ + @Bean + public Queue discordWebhookQueue() { + return QueueBuilder.durable(DISCORD_WEBHOOK_QUEUE) + .withArgument("x-dead-letter-exchange", "") + .withArgument("x-dead-letter-routing-key", DISCORD_WEBHOOK_DLQ) + .withArgument("x-message-ttl", 300000) + .build(); + } + + @Bean + public Queue discordWebhookDLQ() { + return QueueBuilder.durable(DISCORD_WEBHOOK_DLQ).build(); + } + + @Bean + public Binding discordWebhookBinding() { + return BindingBuilder + .bind(discordWebhookQueue()) + .to(notificationExchange()) + .with(DISCORD_WEBHOOK_ROUTING_KEY); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java new file mode 100644 index 00000000..85e88dcb --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java @@ -0,0 +1,52 @@ +package com.aisip.OnO.backend.config.rabbitmq.consumer; + +import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; +import com.aisip.OnO.backend.config.rabbitmq.message.DiscordWebhookMessage; +import com.aisip.OnO.backend.util.webhook.DiscordWebhookPayload; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Service +public class DiscordWebhookConsumer { + + private final RestTemplate restTemplate; + + @Value("${discord.webhook-url}") + private String webhookUrl; + + public DiscordWebhookConsumer() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(3_000); + factory.setReadTimeout(5_000); + this.restTemplate = new RestTemplate(factory); + } + + @RabbitListener(queues = RabbitMQConfig.DISCORD_WEBHOOK_QUEUE, concurrency = "1-3") + public void handleWebhookMessage(DiscordWebhookMessage message) { + DiscordWebhookPayload payload = message.getPayload(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + restTemplate.postForEntity(webhookUrl, new HttpEntity<>(payload, headers), String.class); + log.info("Discord webhook 전송 완료: {}", payload.embeds().get(0).title()); + } catch (Exception e) { + log.error("Discord webhook 전송 실패: {}", e.getMessage(), e); + throw new RuntimeException("Discord webhook 전송 실패", e); + } + } + + @RabbitListener(queues = RabbitMQConfig.DISCORD_WEBHOOK_DLQ) + public void handleWebhookDLQ(DiscordWebhookMessage message) { + DiscordWebhookPayload payload = message.getPayload(); + String title = payload.embeds().isEmpty() ? "(제목 없음)" : payload.embeds().get(0).title(); + log.error("Discord webhook DLQ — 최종 전송 실패, 수동 확인 필요. title={}", title); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java index 969b120e..9feb94f2 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java @@ -4,6 +4,7 @@ import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; import com.aisip.OnO.backend.config.rabbitmq.message.ProblemAnalysisMessage; import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; +import com.aisip.OnO.backend.problem.service.ProblemAnalysisFailureService; import com.aisip.OnO.backend.problem.service.ProblemAnalysisService; import com.aisip.OnO.backend.util.ai.NonRetryableAnalysisException; import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; @@ -23,6 +24,7 @@ public class ProblemAnalysisConsumer { private final ProblemAnalysisService analysisService; + private final ProblemAnalysisFailureService analysisFailureService; private final DiscordWebhookNotificationService discordWebhookNotificationService; /** @@ -30,7 +32,7 @@ public class ProblemAnalysisConsumer { * - concurrency: 동시 처리 개수 (1-3개, OpenAI Rate Limit 고려) * - 예외 발생 시 자동으로 재시도 → 실패하면 DLQ로 이동 */ - @RabbitListener(queues = RabbitMQConfig.GPT_ANALYSIS_QUEUE, concurrency = "1-3") + @RabbitListener(queues = RabbitMQConfig.GPT_ANALYSIS_QUEUE, concurrency = "1-2") public void handleAnalysisMessage(ProblemAnalysisMessage message) { log.info("RabbitMQ message received - queue: {}, operation: {}, problemId: {}, messageRetryCount: {}", RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", message.getProblemId(), message.getRetryCount()); @@ -49,7 +51,8 @@ public void handleAnalysisMessage(ProblemAnalysisMessage message) { // 상태는 Service에서 FAILED로 업데이트됨. 재큐잉 없이 종료(ACK) return; } catch (ApplicationException e) { - if (e.getErrorCase() == ProblemErrorCase.PROBLEM_NOT_FOUND) { + if (e.getErrorCase() == ProblemErrorCase.PROBLEM_NOT_FOUND + || e.getErrorCase() == ProblemErrorCase.PROBLEM_ANALYSIS_NOT_FOUND) { log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}", RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "resource_not_found", message.getProblemId(), message.getRetryCount()); @@ -94,6 +97,14 @@ public void handleAnalysisDLQ(ProblemAnalysisMessage message) { message.getRetryCount() ); + // DLQ 도달 = 모든 재시도 소진. PROCESSING 고착 방지를 위해 FAILED로 전이 + try { + analysisFailureService.markFailed(message.getProblemId(), "최대 재시도 횟수 초과 또는 큐 만료"); + } catch (Exception e) { + log.error("RabbitMQ DLQ markFailed failed - problemId: {}, error: {}", + message.getProblemId(), e.getMessage()); + } + try { discordWebhookNotificationService.sendErrorNotification( "RabbitMQ DLQ - GPT Analysis", diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/message/DiscordWebhookMessage.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/message/DiscordWebhookMessage.java new file mode 100644 index 00000000..f3588ed4 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/message/DiscordWebhookMessage.java @@ -0,0 +1,17 @@ +package com.aisip.OnO.backend.config.rabbitmq.message; + +import com.aisip.OnO.backend.util.webhook.DiscordWebhookPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class DiscordWebhookMessage implements Serializable { + + private DiscordWebhookPayload payload; + private String dedupKey; +} diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/DiscordWebhookProducer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/DiscordWebhookProducer.java new file mode 100644 index 00000000..1fa91018 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/DiscordWebhookProducer.java @@ -0,0 +1,30 @@ +package com.aisip.OnO.backend.config.rabbitmq.producer; + +import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; +import com.aisip.OnO.backend.config.rabbitmq.message.DiscordWebhookMessage; +import com.aisip.OnO.backend.util.webhook.DiscordWebhookPayload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DiscordWebhookProducer { + + private final RabbitTemplate rabbitTemplate; + + public void send(DiscordWebhookPayload payload, String dedupKey) { + try { + rabbitTemplate.convertAndSend( + RabbitMQConfig.NOTIFICATION_EXCHANGE, + RabbitMQConfig.DISCORD_WEBHOOK_ROUTING_KEY, + new DiscordWebhookMessage(payload, dedupKey) + ); + log.debug("Discord webhook 메시지 큐 전송: {}", payload.embeds().get(0).title()); + } catch (Exception e) { + log.error("Discord webhook 큐 전송 실패: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/folder/dto/FolderResponseDto.java b/src/main/java/com/aisip/OnO/backend/folder/dto/FolderResponseDto.java index 42f0c30f..a0cebf5e 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/dto/FolderResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/folder/dto/FolderResponseDto.java @@ -1,13 +1,13 @@ package com.aisip.OnO.backend.folder.dto; import com.aisip.OnO.backend.folder.entity.Folder; -import com.aisip.OnO.backend.problem.dto.ProblemResponseDto; import lombok.AccessLevel; import lombok.Builder; import org.jetbrains.annotations.NotNull; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; @Builder(access = AccessLevel.PRIVATE) public record FolderResponseDto ( @@ -29,15 +29,29 @@ public record FolderResponseDto ( LocalDateTime updateAt ) { public static FolderResponseDto from(@NotNull Folder folder, List problemIdList) { + return from(folder, problemIdList, Map.of()); + } + + public static FolderResponseDto from( + @NotNull Folder folder, + List problemIdList, + Map problemCountsByFolderId + ) { FolderThumbnailResponseDto parentFolder = folder.getParentFolder() != null - ? FolderThumbnailResponseDto.from(folder.getParentFolder()) + ? FolderThumbnailResponseDto.from( + folder.getParentFolder(), + problemCountsByFolderId.getOrDefault(folder.getParentFolder().getId(), 0L) + ) : null; List subFolderList = folder.getSubFolderList() != null ? folder.getSubFolderList() .stream() - .map(FolderThumbnailResponseDto::from) + .map(subFolder -> FolderThumbnailResponseDto.from( + subFolder, + problemCountsByFolderId.getOrDefault(subFolder.getId(), 0L) + )) .toList() : List.of(); @@ -52,4 +66,4 @@ public static FolderResponseDto from(@NotNull Folder folder, List problemI .updateAt(folder.getUpdatedAt()) .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/folder/dto/FolderThumbnailResponseDto.java b/src/main/java/com/aisip/OnO/backend/folder/dto/FolderThumbnailResponseDto.java index 6a84e1ca..0ebff906 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/dto/FolderThumbnailResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/folder/dto/FolderThumbnailResponseDto.java @@ -8,12 +8,18 @@ @Builder(access = AccessLevel.PRIVATE) public record FolderThumbnailResponseDto( Long folderId, - String folderName + String folderName, + Long problemCount ) { - public static FolderThumbnailResponseDto from(@NotNull Folder folder) { + public static FolderThumbnailResponseDto from(@NotNull Folder folder, Long problemCount) { return FolderThumbnailResponseDto.builder() .folderId(folder.getId()) .folderName(folder.getName()) + .problemCount(problemCount) .build(); } + + public static FolderThumbnailResponseDto from(@NotNull Folder folder) { + return from(folder, 0L); + } } diff --git a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java index 00540d31..facee834 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java @@ -19,4 +19,12 @@ public interface FolderRepository extends JpaRepository, FolderRep @Modifying @Query("delete from Folder f where f.id in :folderIds") void deleteAllByIdIn(@Param("folderIds") Collection folderIds); + + @Query(""" + SELECT p.folder.id, COUNT(p.id) + FROM Problem p + WHERE p.folder.id IN :folderIds + GROUP BY p.folder.id + """) + List countProblemsByFolderIds(@Param("folderIds") Collection folderIds); } diff --git a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryCustom.java b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryCustom.java index 39679984..4d9715b4 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryCustom.java +++ b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryCustom.java @@ -2,7 +2,9 @@ import com.aisip.OnO.backend.folder.entity.Folder; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; public interface FolderRepositoryCustom { @@ -17,6 +19,8 @@ public interface FolderRepositoryCustom { List findProblemIdsByFolder(Long folderId); + Map> findProblemIdsByFolderIds(Collection folderIds); + /** * 커서 기반 하위 폴더 조회 * @param folderId 부모 폴더 ID diff --git a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryImpl.java index e62b678e..172bd034 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryImpl.java +++ b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryImpl.java @@ -2,11 +2,15 @@ import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.folder.entity.QFolder; +import com.querydsl.core.Tuple; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import static com.aisip.OnO.backend.folder.entity.QFolder.folder; import static com.aisip.OnO.backend.problem.entity.QProblem.problem; @@ -73,6 +77,23 @@ public List findProblemIdsByFolder(Long folderId) { .fetch(); } + @Override + public Map> findProblemIdsByFolderIds(Collection folderIds) { + if (folderIds == null || folderIds.isEmpty()) { + return Map.of(); + } + List rows = queryFactory + .select(problem.folder.id, problem.id) + .from(problem) + .where(problem.folder.id.in(folderIds)) + .orderBy(problem.folder.id.asc(), problem.id.asc()) + .fetch(); + return rows.stream().collect(Collectors.groupingBy( + t -> t.get(problem.folder.id), + Collectors.mapping(t -> t.get(problem.id), Collectors.toList()) + )); + } + @Override public List findSubFoldersWithCursor(Long folderId, Long cursor, int size) { var query = queryFactory diff --git a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java index 7e4c6b23..d6f35370 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java +++ b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java @@ -8,7 +8,6 @@ import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.folder.exception.FolderErrorCase; import com.aisip.OnO.backend.folder.repository.FolderRepository; -import com.aisip.OnO.backend.problem.dto.ProblemResponseDto; import com.aisip.OnO.backend.problem.service.ProblemService; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; @@ -55,7 +54,7 @@ public FolderResponseDto findRootFolder(Long userId) { log.info("userId : {} find root folder id: {}", userId, rootFolder.getId()); List problemIdList = folderRepository.findProblemIdsByFolder(rootFolder.getId()); - return FolderResponseDto.from(rootFolder, problemIdList); + return toFolderResponseDto(rootFolder, problemIdList); } @Transactional(readOnly = true) @@ -64,7 +63,7 @@ public FolderResponseDto findFolderForAdmin(Long folderId) { .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); List problemIdList = folderRepository.findProblemIdsByFolder(folder.getId()); - return FolderResponseDto.from(folder, problemIdList); + return toFolderResponseDto(folder, problemIdList); } @Transactional(readOnly = true) @@ -72,7 +71,7 @@ public FolderResponseDto findFolder(Long folderId, Long userId) { Folder folder = findFolderWithDetailsOwnedByUser(folderId, userId); List problemIdList = folderRepository.findProblemIdsByFolder(folder.getId()); - return FolderResponseDto.from(folder, problemIdList); + return toFolderResponseDto(folder, problemIdList); } @Transactional(readOnly = true) @@ -94,19 +93,32 @@ public List findAllUserFolderThumbnails(Long userId) return folderList.isEmpty() ? List.of() - : folderList.stream().map(FolderThumbnailResponseDto::from).collect(Collectors.toList()); + : toFolderThumbnailDtos(folderList); } public List findAllUserFolders(Long userId) { List folders = folderRepository.findAllFoldersWithDetailsByUserId(userId); + if (folders.isEmpty()) { + return List.of(); + } + + List folderIds = folders.stream().map(Folder::getId).toList(); + Map> problemIdsByFolder = folderRepository.findProblemIdsByFolderIds(folderIds); + + Set thumbnailFolderIds = new HashSet<>(); + for (Folder f : folders) { + if (f.getParentFolder() != null) thumbnailFolderIds.add(f.getParentFolder().getId()); + if (f.getSubFolderList() != null) f.getSubFolderList().forEach(sf -> thumbnailFolderIds.add(sf.getId())); + } + Map problemCounts = findProblemCountsByFolderIds(thumbnailFolderIds); log.info("userId : {} find All user folders", userId); - return folders.isEmpty() - ? List.of() - : folders.stream().map(folder -> { - List problemIdList = folderRepository.findProblemIdsByFolder(folder.getId()); - return FolderResponseDto.from(folder, problemIdList); - }).toList(); + return folders.stream() + .map(folder -> FolderResponseDto.from( + folder, + problemIdsByFolder.getOrDefault(folder.getId(), List.of()), + problemCounts)) + .toList(); } private void createDefaultSubFolder(Folder rootFolder, Long userId) { @@ -217,9 +229,7 @@ public CursorPageResponse findSubFoldersWithCursor(L List content = hasNext ? folders.subList(0, size) : folders; Long nextCursor = hasNext ? content.get(content.size() - 1).getId() : null; - List dtoList = content.stream() - .map(FolderThumbnailResponseDto::from) - .collect(Collectors.toList()); + List dtoList = toFolderThumbnailDtos(content); log.info("folderId: {} find subfolders with cursor: {}, size: {}, hasNext: {}", folderId, cursor, size, hasNext); return CursorPageResponse.of(dtoList, nextCursor, hasNext, size); @@ -253,11 +263,50 @@ public CursorPageResponse findAllUserFolderThumbnail List content = hasNext ? folders.subList(0, size) : folders; Long nextCursor = hasNext ? content.get(content.size() - 1).getId() : null; - List dtoList = content.stream() - .map(FolderThumbnailResponseDto::from) - .collect(Collectors.toList()); + List dtoList = toFolderThumbnailDtos(content); log.info("userId: {} find all folder thumbnails with cursor: {}, size: {}, hasNext: {}", userId, cursor, size, hasNext); return CursorPageResponse.of(dtoList, nextCursor, hasNext, size); } + + private FolderResponseDto toFolderResponseDto(Folder folder, List problemIdList) { + Set thumbnailFolderIds = new HashSet<>(); + if (folder.getParentFolder() != null) { + thumbnailFolderIds.add(folder.getParentFolder().getId()); + } + if (folder.getSubFolderList() != null) { + folder.getSubFolderList().stream() + .map(Folder::getId) + .forEach(thumbnailFolderIds::add); + } + + Map problemCountsByFolderId = findProblemCountsByFolderIds(thumbnailFolderIds); + return FolderResponseDto.from(folder, problemIdList, problemCountsByFolderId); + } + + private List toFolderThumbnailDtos(List folders) { + List folderIds = folders.stream() + .map(Folder::getId) + .toList(); + Map problemCountsByFolderId = findProblemCountsByFolderIds(folderIds); + + return folders.stream() + .map(folder -> FolderThumbnailResponseDto.from( + folder, + problemCountsByFolderId.getOrDefault(folder.getId(), 0L) + )) + .collect(Collectors.toList()); + } + + private Map findProblemCountsByFolderIds(Collection folderIds) { + if (folderIds == null || folderIds.isEmpty()) { + return Map.of(); + } + + return folderRepository.countProblemsByFolderIds(folderIds).stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + } } diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarController.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarController.java index 90a72f68..1605a70b 100644 --- a/src/main/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarController.java +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarController.java @@ -1,12 +1,16 @@ package com.aisip.OnO.backend.learningcalendar.controller; import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarMoodRequestDto; +import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarMoodResponseDto; import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarResponseDto; import com.aisip.OnO.backend.learningcalendar.service.LearningCalendarService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -29,6 +33,12 @@ public CommonResponse getLearningCalendar( return CommonResponse.success(learningCalendarService.getLearningCalendar(userId, year, month)); } + @PatchMapping("/mood") + public CommonResponse updateMood(@RequestBody LearningCalendarMoodRequestDto request) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(learningCalendarService.updateMood(userId, request)); + } + private void validateYearMonth(int year, int month) { if (year < 1 || month < 1 || month > 12) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."); diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarMoodRequestDto.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarMoodRequestDto.java new file mode 100644 index 00000000..ba103e33 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarMoodRequestDto.java @@ -0,0 +1,9 @@ +package com.aisip.OnO.backend.learningcalendar.dto; + +import java.time.LocalDate; + +public record LearningCalendarMoodRequestDto( + LocalDate date, + String emojiKey +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarMoodResponseDto.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarMoodResponseDto.java new file mode 100644 index 00000000..9e6b1c41 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarMoodResponseDto.java @@ -0,0 +1,9 @@ +package com.aisip.OnO.backend.learningcalendar.dto; + +import java.time.LocalDate; + +public record LearningCalendarMoodResponseDto( + LocalDate date, + String emojiKey +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarResponseDto.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarResponseDto.java index 3ec4d35d..9d965435 100644 --- a/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarResponseDto.java @@ -22,7 +22,8 @@ public record DailyStudyRecord( int reviewCount, int noteWriteCount, int studyMinutes, - List reviewedItems + List reviewedItems, + String moodEmojiKey ) { } } diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/entity/LearningCalendarMood.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/entity/LearningCalendarMood.java new file mode 100644 index 00000000..41b611ca --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/entity/LearningCalendarMood.java @@ -0,0 +1,52 @@ +package com.aisip.OnO.backend.learningcalendar.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "learning_calendar_mood", + uniqueConstraints = @UniqueConstraint(name = "uk_learning_calendar_mood_user_date", columnNames = {"user_id", "study_date"})) +public class LearningCalendarMood extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "study_date", nullable = false) + private LocalDate studyDate; + + @Column(name = "emoji_key", nullable = false, length = 80) + private String emojiKey; + + public static LearningCalendarMood create(Long userId, LocalDate studyDate, String emojiKey) { + return LearningCalendarMood.builder() + .userId(userId) + .studyDate(studyDate) + .emojiKey(emojiKey) + .build(); + } + + public void updateEmojiKey(String emojiKey) { + this.emojiKey = emojiKey; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/exception/LearningCalendarErrorCase.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/exception/LearningCalendarErrorCase.java new file mode 100644 index 00000000..e7e142c4 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/exception/LearningCalendarErrorCase.java @@ -0,0 +1,17 @@ +package com.aisip.OnO.backend.learningcalendar.exception; + +import com.aisip.OnO.backend.common.exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum LearningCalendarErrorCase implements ErrorCase { + + CALENDAR_RECORD_NOT_FOUND(404, 12001, "해당 날짜 학습 기록이 없습니다."), + INVALID_DATE_FORMAT(400, 12002, "날짜 형식이 올바르지 않습니다."); + + private final Integer httpStatusCode; + private final Integer errorCode; + private final String message; +} diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarMoodRepository.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarMoodRepository.java new file mode 100644 index 00000000..2295e6bf --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarMoodRepository.java @@ -0,0 +1,15 @@ +package com.aisip.OnO.backend.learningcalendar.repository; + +import com.aisip.OnO.backend.learningcalendar.entity.LearningCalendarMood; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface LearningCalendarMoodRepository extends JpaRepository { + + Optional findByUserIdAndStudyDate(Long userId, LocalDate studyDate); + + List findAllByUserIdAndStudyDateBetween(Long userId, LocalDate startDate, LocalDate endDate); +} diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarQueryRepository.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarQueryRepository.java index 757ba4f2..7ef7b47f 100644 --- a/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarQueryRepository.java +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarQueryRepository.java @@ -128,6 +128,27 @@ public List findDistinctNoteWriteDatesTotal(Long userId) { .toList(); } + public boolean existsStudyRecord(Long userId, LocalDate date) { + LocalDateTime start = date.atStartOfDay(); + LocalDateTime end = date.atTime(23, 59, 59); + Integer reviewExists = queryFactory + .selectOne() + .from(problemSolve) + .where(problemSolve.userId.eq(userId) + .and(problemSolve.practicedAt.between(start, end))) + .fetchFirst(); + if (reviewExists != null) { + return true; + } + Integer noteWriteExists = queryFactory + .selectOne() + .from(problem) + .where(problem.userId.eq(userId) + .and(problem.createdAt.between(start, end))) + .fetchFirst(); + return noteWriteExists != null; + } + private int secondsToMinutes(Integer seconds) { return seconds == null ? 0 : seconds / 60; } diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java index 7b304ed1..9b4ceaa1 100644 --- a/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java @@ -1,6 +1,13 @@ package com.aisip.OnO.backend.learningcalendar.service; +import com.aisip.OnO.backend.common.emoji.CustomEmojiValidator; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarMoodRequestDto; +import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarMoodResponseDto; import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarResponseDto; +import com.aisip.OnO.backend.learningcalendar.entity.LearningCalendarMood; +import com.aisip.OnO.backend.learningcalendar.exception.LearningCalendarErrorCase; +import com.aisip.OnO.backend.learningcalendar.repository.LearningCalendarMoodRepository; import com.aisip.OnO.backend.learningcalendar.repository.LearningCalendarQueryRepository; import com.aisip.OnO.backend.util.redis.StreakCacheService; import lombok.RequiredArgsConstructor; @@ -24,6 +31,8 @@ public class LearningCalendarService { private final LearningCalendarQueryRepository calendarRepository; private final StreakCacheService streakCacheService; + private final LearningCalendarMoodRepository moodRepository; + private final CustomEmojiValidator customEmojiValidator; public LearningCalendarResponseDto getLearningCalendar(Long userId, int year, int month) { return getLearningCalendar(userId, year, month, LocalDate.now()); @@ -45,13 +54,17 @@ LearningCalendarResponseDto getLearningCalendar(Long userId, int year, int month Map> reviewedItems = toReviewedItemsMap( calendarRepository.findReviewItems(userId, start, end) ); + Map moodEmojiKeys = moodRepository.findAllByUserIdAndStudyDateBetween(userId, startDate, endDate) + .stream() + .collect(Collectors.toMap(LearningCalendarMood::getStudyDate, LearningCalendarMood::getEmojiKey)); List records = startDate.datesUntil(endDate.plusDays(1)) .map(date -> buildDailyRecord( date, reviewStats.get(date), noteWriteStats.get(date), - reviewedItems.getOrDefault(date, List.of()) + reviewedItems.getOrDefault(date, List.of()), + moodEmojiKeys.get(date) )) .toList(); @@ -71,11 +84,27 @@ LearningCalendarResponseDto getLearningCalendar(Long userId, int year, int month .build(); } + @Transactional + public LearningCalendarMoodResponseDto updateMood(Long userId, LearningCalendarMoodRequestDto request) { + if (request == null || request.date() == null) { + throw new ApplicationException(LearningCalendarErrorCase.INVALID_DATE_FORMAT); + } + customEmojiValidator.validate(request.emojiKey()); + if (!calendarRepository.existsStudyRecord(userId, request.date())) { + throw new ApplicationException(LearningCalendarErrorCase.CALENDAR_RECORD_NOT_FOUND); + } + LearningCalendarMood mood = moodRepository.findByUserIdAndStudyDate(userId, request.date()) + .orElseGet(() -> moodRepository.save(LearningCalendarMood.create(userId, request.date(), request.emojiKey()))); + mood.updateEmojiKey(request.emojiKey()); + return new LearningCalendarMoodResponseDto(mood.getStudyDate(), mood.getEmojiKey()); + } + private LearningCalendarResponseDto.DailyStudyRecord buildDailyRecord( LocalDate date, LearningCalendarQueryRepository.DailyReviewStat reviewStat, LearningCalendarQueryRepository.DailyNoteWriteStat noteWriteStat, - List reviewedItems + List reviewedItems, + String moodEmojiKey ) { int reviewCount = reviewStat == null ? 0 : toInt(reviewStat.reviewCount()); int noteWriteCount = noteWriteStat == null ? 0 : toInt(noteWriteStat.noteWriteCount()); @@ -88,6 +117,7 @@ private LearningCalendarResponseDto.DailyStudyRecord buildDailyRecord( .noteWriteCount(noteWriteCount) .studyMinutes(studyMinutes) .reviewedItems(reviewedItems) + .moodEmojiKey(moodEmojiKey) .build(); } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/controller/PracticeNoteController.java b/src/main/java/com/aisip/OnO/backend/practicenote/controller/PracticeNoteController.java index 8f0027a8..02b9dc00 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/controller/PracticeNoteController.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/controller/PracticeNoteController.java @@ -73,9 +73,10 @@ public CommonResponse registerPractice(@RequestBody PracticeNoteRegisterDt // ✅ 복습 완료 횟수 증가 @PatchMapping("/{practiceId}/complete") - public CommonResponse addPracticeCount(@PathVariable("practiceId") Long practiceId) { + public CommonResponse addPracticeCount(@PathVariable("practiceId") Long practiceId, + @RequestBody(required = false) PracticeNoteCompleteRequestDto request) { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - practiceNoteService.addPracticeNoteCount(userId, practiceId); + practiceNoteService.addPracticeNoteCount(userId, practiceId, request); log.info("userId: {} completed problem practice with practiceNoteId: {}", userId, practiceId); return CommonResponse.success("복습을 완료했습니다."); diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNoteCompleteRequestDto.java b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNoteCompleteRequestDto.java new file mode 100644 index 00000000..9b174d52 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNoteCompleteRequestDto.java @@ -0,0 +1,6 @@ +package com.aisip.OnO.backend.practicenote.dto; + +public record PracticeNoteCompleteRequestDto( + String moodEmojiKey +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNoteDetailResponseDto.java b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNoteDetailResponseDto.java index a6863b7a..e434a949 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNoteDetailResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNoteDetailResponseDto.java @@ -21,6 +21,8 @@ public record PracticeNoteDetailResponseDto( LocalDateTime lastSolvedAt, + String lastSessionMoodEmojiKey, + LocalDateTime createdAt, LocalDateTime updatedAt @@ -41,6 +43,7 @@ public static PracticeNoteDetailResponseDto from(@NotNull PracticeNote practiceN .problemIdList(problemIdList) .practiceNotification(notificationDto) .lastSolvedAt(practiceNote.getLastSolvedAt()) + .lastSessionMoodEmojiKey(practiceNote.getLastSessionMoodEmojiKey()) .createdAt(practiceNote.getCreatedAt()) .updatedAt(practiceNote.getUpdatedAt()) .build(); diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNoteThumbnailResponseDto.java b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNoteThumbnailResponseDto.java index a8f09af0..da64cef7 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNoteThumbnailResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNoteThumbnailResponseDto.java @@ -15,7 +15,9 @@ public record PracticeNoteThumbnailResponseDto( Long practiceCount, - LocalDateTime lastSolvedAt + LocalDateTime lastSolvedAt, + + String lastSessionMoodEmojiKey ) { public static PracticeNoteThumbnailResponseDto from(@NotNull PracticeNote practiceNote) { return PracticeNoteThumbnailResponseDto.builder() @@ -23,6 +25,7 @@ public static PracticeNoteThumbnailResponseDto from(@NotNull PracticeNote practi .practiceTitle(practiceNote.getTitle()) .practiceCount(practiceNote.getPracticeCount()) .lastSolvedAt(practiceNote.getLastSolvedAt()) + .lastSessionMoodEmojiKey(practiceNote.getLastSessionMoodEmojiKey()) .build(); } } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/entity/PracticeNote.java b/src/main/java/com/aisip/OnO/backend/practicenote/entity/PracticeNote.java index 1d123131..d8419312 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/entity/PracticeNote.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/entity/PracticeNote.java @@ -35,6 +35,9 @@ public class PracticeNote extends BaseEntity { private LocalDateTime lastSolvedAt; + @Column(length = 80) + private String lastSessionMoodEmojiKey; + @Embedded private PracticeNotification practiceNotification; @@ -63,6 +66,11 @@ public void updatePracticeNoteCount() { this.lastSolvedAt = LocalDateTime.now(); } + public void updatePracticeNoteCount(String moodEmojiKey) { + updatePracticeNoteCount(); + this.lastSessionMoodEmojiKey = moodEmojiKey; + } + public void updateNotification(PracticeNotification practiceNotification) { this.practiceNotification = practiceNotification; } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java index a84f5abf..9e77b010 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.practicenote.service; import com.aisip.OnO.backend.admin.dto.AdminPracticeNoteResponseDto; +import com.aisip.OnO.backend.common.emoji.CustomEmojiValidator; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.common.response.CursorPageResponse; import com.aisip.OnO.backend.mission.service.MissionLogService; @@ -43,7 +44,7 @@ @RequiredArgsConstructor @Transactional public class PracticeNoteService { - private static final String DEFAULT_PRACTICE_NOTE_TITLE = "복습노트"; + private static final String DEFAULT_PRACTICE_NOTE_TITLE = "복습 세트"; private final ProblemRepository problemRepository; @@ -57,6 +58,8 @@ public class PracticeNoteService { private final UserRepository userRepository; + private final CustomEmojiValidator customEmojiValidator; + private PracticeNote getPracticeEntity(Long practiceId, Long userId){ PracticeNote practiceNote = practiceNoteRepository.findById(practiceId) @@ -137,8 +140,14 @@ public List findAllPracticesByUser(Long userId){ } public void addPracticeNoteCount(Long userId, Long practiceId) { + addPracticeNoteCount(userId, practiceId, null); + } + + public void addPracticeNoteCount(Long userId, Long practiceId, PracticeNoteCompleteRequestDto request) { PracticeNote practiceNote = getPracticeEntity(practiceId, userId); - practiceNote.updatePracticeNoteCount(); + String moodEmojiKey = request == null ? null : request.moodEmojiKey(); + customEmojiValidator.validateNullable(moodEmojiKey); + practiceNote.updatePracticeNoteCount(moodEmojiKey); // 복습노트 사용 미션 등록 missionLogService.registerNotePracticeMission(userId, practiceId); diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java index 595b3a83..8dfea878 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java @@ -30,7 +30,7 @@ public void executeInternal(JobExecutionContext context) { new NotificationRequestDto( "User Token", "오답노트를 복습할 시간이예요!", - practiceTitle + " 복습노트를 공부할 시간입니다!", + practiceTitle + " 복습 세트를 공부할 시간입니다!", Map.of("practiceId", String.valueOf(practiceId)) ) ); diff --git a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java index 513f0345..612c3686 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java +++ b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java @@ -2,6 +2,7 @@ import com.aisip.OnO.backend.common.response.CommonResponse; import com.aisip.OnO.backend.common.response.CursorPageResponse; +import com.aisip.OnO.backend.problem.dto.AddProblemImageUrlsRequest; import com.aisip.OnO.backend.problem.dto.ProblemAnalysisResponseDto; import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.dto.ProblemDeleteRequestDto; @@ -16,6 +17,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.propertyeditors.CustomNumberEditor; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -165,6 +167,19 @@ public CommonResponse uploadProblemImages( return CommonResponse.success("이미지 업로드가 시작되었습니다."); } + // ✅ 문제 이미지 URL 추가 (Presigned URL 방식) + @PostMapping("/{problemId}/imageData/urls") + public CommonResponse addProblemImageDataUrls( + @PathVariable("problemId") Long problemId, + @Validated @RequestBody AddProblemImageUrlsRequest request + ) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + problemService.addImageDataUrls(problemId, userId, request); + problemService.analysisProblem(problemId, userId); + + return CommonResponse.success(null); + } + // ✅ 문제 이미지 비동기 업로드 @PatchMapping("/{problemId}/no-image") public CommonResponse updateProblemAnalysisStatus( diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/AddProblemImageUrlsRequest.java b/src/main/java/com/aisip/OnO/backend/problem/dto/AddProblemImageUrlsRequest.java new file mode 100644 index 00000000..ebe45fbd --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/AddProblemImageUrlsRequest.java @@ -0,0 +1,10 @@ +package com.aisip.OnO.backend.problem.dto; + +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record AddProblemImageUrlsRequest(@NotNull List imageDataList) { + + public record ImageUrlItem(String imageUrl, String problemImageType) {} +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisDbService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisDbService.java new file mode 100644 index 00000000..21c7ed5a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisDbService.java @@ -0,0 +1,71 @@ +package com.aisip.OnO.backend.problem.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.problem.entity.AnalysisStatus; +import com.aisip.OnO.backend.problem.entity.ProblemAnalysis; +import com.aisip.OnO.backend.problem.entity.ProblemImageData; +import com.aisip.OnO.backend.problem.entity.ProblemImageType; +import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; +import com.aisip.OnO.backend.problem.repository.ProblemAnalysisRepository; +import com.aisip.OnO.backend.problem.repository.ProblemImageDataRepository; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.util.ai.ProblemAnalysisResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * ProblemAnalysisService의 DB 접근 작업을 분리한 별도 빈. + * analyzeProblemSync에서 self-invocation 없이 AOP 프록시를 거쳐 호출되어야 @Transactional이 정상 동작함. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProblemAnalysisDbService { + + private final ProblemRepository problemRepository; + private final ProblemImageDataRepository problemImageDataRepository; + private final ProblemAnalysisRepository analysisRepository; + + @Transactional(readOnly = true) + public AnalysisPreparation fetchAnalysisPreparation(Long problemId) { + problemRepository.findById(problemId) + .orElseThrow(() -> new ApplicationException(ProblemErrorCase.PROBLEM_NOT_FOUND)); + + List imageUrls = problemImageDataRepository.findAllByProblemId(problemId) + .stream() + .filter(data -> data.getProblemImageType().equals(ProblemImageType.PROBLEM_IMAGE)) + .map(ProblemImageData::getImageUrl) + .toList(); + + ProblemAnalysis analysis = analysisRepository.findByProblemId(problemId) + .orElseThrow(() -> new ApplicationException(ProblemErrorCase.PROBLEM_ANALYSIS_NOT_FOUND)); + + return new AnalysisPreparation(imageUrls, analysis.getStatus().equals(AnalysisStatus.COMPLETED)); + } + + @Transactional + public void saveAnalysisSuccess(Long problemId, ProblemAnalysisResult result, String keyPointsJson) { + ProblemAnalysis analysis = analysisRepository.findByProblemId(problemId) + .orElseThrow(() -> new ApplicationException(ProblemErrorCase.PROBLEM_ANALYSIS_NOT_FOUND)); + // 다른 스레드가 먼저 완료한 경우 덮어쓰기 방지 + if (analysis.getStatus().equals(AnalysisStatus.COMPLETED)) { + log.info("Analysis already COMPLETED by another thread, skipping save. problemId: {}", problemId); + return; + } + analysis.updateWithSuccess( + result.getSubject(), + result.getProblemType(), + keyPointsJson, + result.getSolution(), + result.getCommonMistakes(), + result.getStudyTips() + ); + analysisRepository.save(analysis); + } + + public record AnalysisPreparation(List imageUrls, boolean alreadyCompleted) {} +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java index 4f91c1fb..53e2a709 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java @@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -35,6 +36,7 @@ public class ProblemAnalysisService { private final ObjectMapper objectMapper; private final ProblemImageDataRepository problemImageDataRepository; private final ProblemAnalysisFailureService problemAnalysisFailureService; + private final ProblemAnalysisDbService problemAnalysisDbService; /** * 기존 분석 결과 삭제 @@ -127,38 +129,25 @@ public void updateToRateLimitExceeded(Long problemId) { /** * 동기적으로 문제 이미지를 분석합니다 (RabbitMQ Consumer에서 호출) - * - 동시성 문제 해결: 이미 생성된 PROCESSING 엔티티만 조회하여 업데이트 + * - NOT_SUPPORTED: 이 메서드 자체는 트랜잭션 없이 실행, DB 작업은 problemAnalysisDbService 빈을 통해 각자 독립 트랜잭션으로 처리 + * - self-invocation 문제를 피하기 위해 Phase 1/3의 DB 작업을 별도 빈에서 처리 */ - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void analyzeProblemSync(Long problemId) { log.info("Starting sync analysis for problemId: {}", problemId); try { - // 1. Problem 조회 - Problem problem = problemRepository.findById(problemId) - .orElseThrow(() -> new ApplicationException(ProblemErrorCase.PROBLEM_NOT_FOUND)); - - // 2. 문제 이미지 url 조회 - List problemImageUrls = problemImageDataRepository.findAllByProblemId(problemId) - .stream() - .filter(data -> data.getProblemImageType().equals(ProblemImageType.PROBLEM_IMAGE)) - .map(ProblemImageData::getImageUrl) - .toList(); - - // 3. 기존 분석 조회 (동시성 문제 해결: 새로 생성하지 않고 조회만) - ProblemAnalysis analysis = analysisRepository.findByProblemId(problemId) - .orElseThrow(() -> new ApplicationException(ProblemErrorCase.PROBLEM_ANALYSIS_NOT_FOUND)); - - // 4. 이미 완료된 경우 스킵 - if (analysis.getStatus().equals(AnalysisStatus.COMPLETED)) { + // Phase 1: 짧은 readOnly 트랜잭션으로 필요한 데이터 조회 + ProblemAnalysisDbService.AnalysisPreparation prep = problemAnalysisDbService.fetchAnalysisPreparation(problemId); + if (prep.alreadyCompleted()) { log.info("Analysis already completed for problemId: {}", problemId); return; } - // 5. OpenAI API 호출 (여러 이미지를 하나의 문제로 분석) - ProblemAnalysisResult result = openAIClient.analyzeImages(problemImageUrls); + // Phase 2: 트랜잭션 없이 OpenAI 호출 (DB 커넥션 미점유) + ProblemAnalysisResult result = openAIClient.analyzeImages(prep.imageUrls()); - // 6. keyPoints를 JSON 문자열로 변환 + // Phase 3: 결과를 짧은 쓰기 트랜잭션으로 저장 String keyPointsJson; try { keyPointsJson = objectMapper.writeValueAsString(result.getKeyPoints()); @@ -166,27 +155,15 @@ public void analyzeProblemSync(Long problemId) { log.error("JSON 변환 실패 for problemId: {}", problemId, jsonException); throw new RuntimeException("JSON 변환 실패", jsonException); } - - // 7. 분석 결과 업데이트 (COMPLETED) - analysis.updateWithSuccess( - result.getSubject(), - result.getProblemType(), - keyPointsJson, - result.getSolution(), - result.getCommonMistakes(), - result.getStudyTips() - ); - - analysisRepository.save(analysis); + problemAnalysisDbService.saveAnalysisSuccess(problemId, result, keyPointsJson); log.info("Analysis completed successfully for problemId: {}", problemId); } catch (ApplicationException e) { - // 삭제된 문제는 재시도 가치가 없어 상위 Consumer에서 비재시도 처리한다. - if (e.getErrorCase() == ProblemErrorCase.PROBLEM_NOT_FOUND) { - log.warn("Skipping analysis because problem was deleted or missing. problemId: {}", problemId); + if (e.getErrorCase() == ProblemErrorCase.PROBLEM_NOT_FOUND + || e.getErrorCase() == ProblemErrorCase.PROBLEM_ANALYSIS_NOT_FOUND) { + log.warn("Skipping analysis because problem or analysis was deleted. problemId: {}", problemId); throw e; } - log.error("Application error during sync analysis for problemId: {}", problemId, e); handleAnalysisError(problemId, e); throw e; @@ -197,7 +174,7 @@ public void analyzeProblemSync(Long problemId) { } catch (Exception e) { log.error("Error during sync analysis for problemId: {}", problemId, e); handleAnalysisError(problemId, e); - throw e; // RabbitMQ 재시도를 위해 예외 다시 던짐 + throw e; } } diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 16fdd62a..823a47e4 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -11,6 +11,7 @@ import com.aisip.OnO.backend.problem.entity.ProblemAnalysis; import com.aisip.OnO.backend.problem.entity.ProblemImageType; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; +import com.aisip.OnO.backend.problem.dto.AddProblemImageUrlsRequest; import com.aisip.OnO.backend.problem.dto.ProblemImageDataRegisterDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2BatchDto; @@ -30,6 +31,8 @@ import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveSummary; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedEventType; +import com.aisip.OnO.backend.studyroom.event.StudyRoomActivityEvent; import com.aisip.OnO.backend.tag.entity.ProblemTagMapping; import com.aisip.OnO.backend.tag.entity.Tag; import com.aisip.OnO.backend.tag.exception.TagErrorCase; @@ -38,6 +41,7 @@ import com.aisip.OnO.backend.util.redis.StreakCacheService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.data.domain.Page; @@ -88,6 +92,7 @@ public class ProblemService { private final ProblemTagMappingRepository problemTagMappingRepository; private final RateLimitService rateLimitService; private final StreakCacheService streakCacheService; + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public ProblemResponseDto findProblemForAdmin(Long problemId) { @@ -214,6 +219,8 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) analysisService.createSkippedAnalysis(problem.getId()); streakCacheService.evict(userId); missionLogService.registerProblemWriteMission(userId); + eventPublisher.publishEvent(new StudyRoomActivityEvent( + userId, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", 1))); log.info("userId: {} register problemId: {}", userId, problem.getId()); @@ -271,6 +278,8 @@ public Long registerProblemV2(ProblemRegisterV2Dto problemRegisterV2Dto, Long us analysisService.createSkippedAnalysis(problem.getId()); streakCacheService.evict(userId); missionLogService.registerProblemWriteMission(userId); + eventPublisher.publishEvent(new StudyRoomActivityEvent( + userId, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", 1))); log.info("userId: {} register problem(v2) problemId: {}", userId, problem.getId()); return problem.getId(); @@ -333,6 +342,8 @@ public List registerProblemsV2(ProblemRegisterV2BatchDto problemRegisterV2 streakCacheService.evict(userId); missionLogService.registerProblemWriteMissionBatch(userId, problems.size()); + eventPublisher.publishEvent(new StudyRoomActivityEvent( + userId, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", problems.size()))); List problemIds = problems.stream() .map(Problem::getId) @@ -472,6 +483,22 @@ public void uploadProblemImages(Long problemId, Long userId, List } } + @Transactional + public void addImageDataUrls(Long problemId, Long userId, AddProblemImageUrlsRequest request) { + Problem problem = findProblemEntity(problemId, userId); + for (AddProblemImageUrlsRequest.ImageUrlItem item : request.imageDataList()) { + fileUploadService.validateS3Url(item.imageUrl()); + ProblemImageType imageType = ProblemImageType.valueOf(item.problemImageType()); + ProblemImageData imageData = ProblemImageData.from( + new ProblemImageDataRegisterDto(problemId, item.imageUrl(), imageType)); + imageData.updateProblem(problem); + problemImageDataRepository.save(imageData); + if (imageType == ProblemImageType.SOLVE_IMAGE) { + missionLogService.registerProblemPracticeMission(userId, problemId); + } + } + } + /** * GPT 문제 분석 요청 (RabbitMQ 방식) * - 이미지 유무 확인 후 상태 업데이트 diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/controller/ProblemSolveController.java b/src/main/java/com/aisip/OnO/backend/problemsolve/controller/ProblemSolveController.java index a4a4d361..7f309d7c 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/controller/ProblemSolveController.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/controller/ProblemSolveController.java @@ -5,9 +5,11 @@ import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveResponseDto; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveUpdateDto; import com.aisip.OnO.backend.problemsolve.service.ProblemSolveService; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -88,6 +90,19 @@ public CommonResponse uploadProblemSolveImages( return CommonResponse.success("복습 기록 이미지 업로드가 완료되었습니다."); } + // 복습 기록 이미지 URL 추가 (Presigned URL 방식) + @PostMapping("/{problemSolveId}/image-urls") + public CommonResponse addProblemSolveImageUrls( + @PathVariable("problemSolveId") Long problemSolveId, + @Validated @RequestBody ImageUrlsRequest request) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + problemSolveService.addImageUrls(problemSolveId, userId, request.imageUrls()); + + return CommonResponse.success(null); + } + + public record ImageUrlsRequest(@NotNull List imageUrls) {} + // 복습 기록 수정 @PatchMapping("") public CommonResponse updateProblemSolve(@RequestBody ProblemSolveUpdateDto updateDto) { diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java index e4380ad7..1519e386 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java @@ -15,6 +15,8 @@ import com.aisip.OnO.backend.problem.entity.Problem; import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedEventType; +import com.aisip.OnO.backend.studyroom.event.StudyRoomActivityEvent; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import com.fasterxml.jackson.databind.ObjectMapper; @@ -22,6 +24,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.web.multipart.MultipartFile; import java.util.ArrayList; @@ -46,6 +49,7 @@ public class ProblemSolveService { private final S3DeleteProducer s3DeleteProducer; private final ObjectMapper objectMapper; private final StreakCacheService streakCacheService; + private final ApplicationEventPublisher eventPublisher; @Qualifier("s3UploadExecutor") private final Executor s3UploadExecutor; @@ -135,6 +139,8 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { problem.getConsecutiveCorrectCount() ); problem.updateReviewSchedule(schedule.nextReviewAt(), schedule.reviewInterval(), schedule.consecutiveCorrectCount()); + eventPublisher.publishEvent(new StudyRoomActivityEvent( + userId, StudyRoomFeedEventType.PRACTICE_COMPLETED, java.util.Map.of())); log.info("userId: {} created problem solve: {}, nextReviewAt: {}, mastered: {}", userId, problemSolve.getId(), schedule.nextReviewAt(), schedule.isMastered()); @@ -174,6 +180,24 @@ public void uploadProblemSolveImages(Long problemSolveId, Long userId, List imageUrls) { + ProblemSolve problemSolve = problemSolveRepository.findById(problemSolveId) + .orElseThrow(() -> new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_NOT_FOUND)); + if (!Objects.equals(problemSolve.getUserId(), userId)) { + throw new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_USER_UNMATCHED); + } + int existingCount = problemSolve.getImages() == null ? 0 : problemSolve.getImages().size(); + List imageDataList = IntStream.range(0, imageUrls.size()) + .mapToObj(i -> { + fileUploadService.validateS3Url(imageUrls.get(i)); + return ProblemSolveImageData.create(imageUrls.get(i), existingCount + i); + }) + .collect(Collectors.toList()); + imageDataList.forEach(problemSolve::addImage); + problemSolveImageDataRepository.saveAll(imageDataList); + } + @Transactional public void updateProblemSolve(ProblemSolveUpdateDto dto, Long userId) { ProblemSolve problemSolve = problemSolveRepository.findById(dto.problemSolveId()) diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomChallengeController.java b/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomChallengeController.java new file mode 100644 index 00000000..7566fccc --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomChallengeController.java @@ -0,0 +1,42 @@ +package com.aisip.OnO.backend.studyroom.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.service.StudyRoomChallengeService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/study-room/{roomId}/challenges") +public class StudyRoomChallengeController { + + private final StudyRoomChallengeService challengeService; + + @GetMapping + public CommonResponse> getChallenges(@PathVariable("roomId") Long roomId) { + return CommonResponse.success(challengeService.getChallenges(roomId, currentUserId())); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CommonResponse createChallenge(@PathVariable("roomId") Long roomId, + @RequestBody ChallengeCreateRequest request) { + return CommonResponse.success(challengeService.createChallenge(roomId, currentUserId(), request)); + } + + @DeleteMapping("/{challengeId}") + public CommonResponse deleteChallenge(@PathVariable("roomId") Long roomId, + @PathVariable("challengeId") Long challengeId) { + challengeService.deleteChallenge(roomId, challengeId, currentUserId()); + return CommonResponse.success("챌린지 삭제가 완료되었습니다."); + } + + private Long currentUserId() { + return (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomController.java b/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomController.java new file mode 100644 index 00000000..2905bad3 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomController.java @@ -0,0 +1,111 @@ +package com.aisip.OnO.backend.studyroom.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.service.StudyRoomInviteService; +import com.aisip.OnO.backend.studyroom.service.StudyRoomService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping({"/api/study-room", "/api/study-rooms"}) +public class StudyRoomController { + + private final StudyRoomService studyRoomService; + private final StudyRoomInviteService inviteService; + + @GetMapping + public CommonResponse> getMyRooms() { + return CommonResponse.success(studyRoomService.getMyRooms(currentUserId())); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public CommonResponse createRoom(@RequestBody StudyRoomCreateRequest request) { + return CommonResponse.success(studyRoomService.createRoom(request, currentUserId())); + } + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public CommonResponse createRoomMultipart( + @RequestParam("name") String name, + @RequestPart(value = "thumbnailImage", required = false) MultipartFile thumbnailImage) { + return CommonResponse.success(studyRoomService.createRoom(name, thumbnailImage, currentUserId())); + } + + @GetMapping("/{roomId}") + public CommonResponse getRoom(@PathVariable("roomId") Long roomId) { + return CommonResponse.success(studyRoomService.getRoom(roomId, currentUserId())); + } + + @PatchMapping(value = "/{roomId}", consumes = MediaType.APPLICATION_JSON_VALUE) + public CommonResponse updateRoom(@PathVariable("roomId") Long roomId, + @RequestBody StudyRoomUpdateRequest request) { + return CommonResponse.success(studyRoomService.updateRoom(roomId, currentUserId(), request)); + } + + @PatchMapping(value = "/{roomId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse updateRoomMultipart(@PathVariable("roomId") Long roomId, + @RequestParam(value = "name", required = false) String name, + @RequestPart(value = "thumbnailImage", required = false) MultipartFile thumbnailImage) { + return CommonResponse.success(studyRoomService.updateRoom(roomId, currentUserId(), name, thumbnailImage)); + } + + @DeleteMapping("/{roomId}") + public CommonResponse deleteRoom(@PathVariable("roomId") Long roomId) { + studyRoomService.deleteRoom(roomId, currentUserId()); + return CommonResponse.success("스터디룸 삭제가 완료되었습니다."); + } + + @PostMapping("/{roomId}/invite") + public CommonResponse issueInviteCode(@PathVariable("roomId") Long roomId) { + return CommonResponse.success(inviteService.issueInviteCode(roomId, currentUserId())); + } + + @PostMapping("/join") + public CommonResponse join(@RequestBody StudyRoomJoinRequest request) { + return CommonResponse.success(inviteService.join(request, currentUserId())); + } + + @DeleteMapping("/{roomId}/leave") + public CommonResponse leaveRoom(@PathVariable("roomId") Long roomId) { + studyRoomService.leaveRoom(roomId, currentUserId()); + return CommonResponse.success("스터디룸 탈퇴가 완료되었습니다."); + } + + @DeleteMapping("/{roomId}/members/{memberId}") + public CommonResponse kickMember(@PathVariable("roomId") Long roomId, + @PathVariable("memberId") Long memberId) { + studyRoomService.kickMember(roomId, memberId, currentUserId()); + return CommonResponse.success("스터디룸 멤버 강퇴가 완료되었습니다."); + } + + @PutMapping("/{roomId}/members/me/goal") + public CommonResponse updateGoal(@PathVariable("roomId") Long roomId, + @RequestBody StudyRoomGoalUpdateRequest request) { + return CommonResponse.success(studyRoomService.updateGoal(roomId, currentUserId(), request)); + } + + @PatchMapping(value = "/{roomId}/thumbnail", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse updateThumbnail(@PathVariable("roomId") Long roomId, + @RequestParam("thumbnail") MultipartFile thumbnail) { + return CommonResponse.success(studyRoomService.updateThumbnail(roomId, currentUserId(), thumbnail)); + } + + @PatchMapping(value = "/{roomId}/thumbnail-url", consumes = MediaType.APPLICATION_JSON_VALUE) + public CommonResponse updateThumbnailByUrl(@PathVariable("roomId") Long roomId, + @RequestBody ThumbnailUrlUpdateRequest request) { + return CommonResponse.success(studyRoomService.updateThumbnailByUrl(roomId, currentUserId(), request.thumbnailUrl())); + } + + private Long currentUserId() { + return (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomFeedController.java b/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomFeedController.java new file mode 100644 index 00000000..90cd3dda --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomFeedController.java @@ -0,0 +1,35 @@ +package com.aisip.OnO.backend.studyroom.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.common.response.CursorPageResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.service.StudyRoomFeedService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/study-room/{roomId}/feed") +public class StudyRoomFeedController { + + private final StudyRoomFeedService feedService; + + @GetMapping + public CommonResponse> getFeed(@PathVariable("roomId") Long roomId, + @RequestParam(value = "cursor", required = false) Long cursor, + @RequestParam(value = "size", defaultValue = "30") int size) { + return CommonResponse.success(feedService.getFeed(roomId, currentUserId(), cursor, size)); + } + + @PostMapping("/{feedId}/reactions") + public CommonResponse toggleReaction(@PathVariable("roomId") Long roomId, + @PathVariable("feedId") Long feedId, + @RequestBody ReactionToggleRequest request) { + return CommonResponse.success(feedService.toggleReaction(roomId, feedId, currentUserId(), request)); + } + + private Long currentUserId() { + return (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomSharedProblemController.java b/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomSharedProblemController.java new file mode 100644 index 00000000..20d95d5e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomSharedProblemController.java @@ -0,0 +1,92 @@ +package com.aisip.OnO.backend.studyroom.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.common.response.CursorPageResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.service.StudyRoomSharedProblemCommentService; +import com.aisip.OnO.backend.studyroom.service.StudyRoomSharedProblemService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping({"/api/study-room/{roomId}/shared-problems", "/api/study-rooms/{roomId}/shared-problems"}) +public class StudyRoomSharedProblemController { + + private final StudyRoomSharedProblemService sharedProblemService; + private final StudyRoomSharedProblemCommentService commentService; + + @GetMapping + public CommonResponse> getSharedProblems(@PathVariable("roomId") Long roomId, + @RequestParam(value = "cursor", required = false) Long cursor, + @RequestParam(value = "size", defaultValue = "20") int size) { + return CommonResponse.success(sharedProblemService.getSharedProblems(roomId, currentUserId(), cursor, size)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CommonResponse shareProblem(@PathVariable("roomId") Long roomId, + @RequestBody SharedProblemCreateRequest request) { + return CommonResponse.success(sharedProblemService.shareProblem(roomId, currentUserId(), request)); + } + + @PostMapping("/{sharedProblemId}/reactions") + public CommonResponse toggleReaction(@PathVariable("roomId") Long roomId, + @PathVariable("sharedProblemId") Long sharedProblemId, + @RequestBody ReactionToggleRequest request) { + return CommonResponse.success(sharedProblemService.toggleReaction(roomId, sharedProblemId, currentUserId(), request)); + } + + @GetMapping("/{sharedProblemId}/comments") + public CommonResponse> getComments(@PathVariable("roomId") Long roomId, + @PathVariable("sharedProblemId") Long sharedProblemId, + @RequestParam(value = "cursor", required = false) Long cursor, + @RequestParam(value = "size", defaultValue = "20") int size) { + return CommonResponse.success(commentService.getComments(roomId, sharedProblemId, currentUserId(), cursor, size)); + } + + @PostMapping("/{sharedProblemId}/comments") + @ResponseStatus(HttpStatus.CREATED) + public CommonResponse createComment(@PathVariable("roomId") Long roomId, + @PathVariable("sharedProblemId") Long sharedProblemId, + @RequestBody SharedProblemCommentRequest request) { + return CommonResponse.success(commentService.createComment(roomId, sharedProblemId, currentUserId(), request)); + } + + @PatchMapping("/{sharedProblemId}/comments/{commentId}") + public CommonResponse updateComment(@PathVariable("roomId") Long roomId, + @PathVariable("sharedProblemId") Long sharedProblemId, + @PathVariable("commentId") Long commentId, + @RequestBody SharedProblemCommentRequest request) { + return CommonResponse.success(commentService.updateComment(roomId, sharedProblemId, commentId, currentUserId(), request)); + } + + @PostMapping("/{sharedProblemId}/comments/{commentId}/reactions") + public CommonResponse toggleCommentReaction(@PathVariable("roomId") Long roomId, + @PathVariable("sharedProblemId") Long sharedProblemId, + @PathVariable("commentId") Long commentId, + @RequestBody ReactionToggleRequest request) { + return CommonResponse.success(commentService.toggleReaction(roomId, sharedProblemId, commentId, currentUserId(), request)); + } + + @DeleteMapping("/{sharedProblemId}/comments/{commentId}") + public CommonResponse deleteComment(@PathVariable("roomId") Long roomId, + @PathVariable("sharedProblemId") Long sharedProblemId, + @PathVariable("commentId") Long commentId) { + commentService.deleteComment(roomId, sharedProblemId, commentId, currentUserId()); + return CommonResponse.success("공유 문제 댓글 삭제가 완료되었습니다."); + } + + @DeleteMapping("/{sharedProblemId}") + public CommonResponse deleteSharedProblem(@PathVariable("roomId") Long roomId, + @PathVariable("sharedProblemId") Long sharedProblemId) { + sharedProblemService.deleteSharedProblem(roomId, sharedProblemId, currentUserId()); + return CommonResponse.success("공유 문제 삭제가 완료되었습니다."); + } + + private Long currentUserId() { + return (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomWeeklyReportController.java b/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomWeeklyReportController.java new file mode 100644 index 00000000..c29f1420 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/controller/StudyRoomWeeklyReportController.java @@ -0,0 +1,34 @@ +package com.aisip.OnO.backend.studyroom.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.service.StudyRoomWeeklyReportService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/study-room/{roomId}/weekly-reports") +public class StudyRoomWeeklyReportController { + + private final StudyRoomWeeklyReportService reportService; + + @GetMapping + public CommonResponse> getReports(@PathVariable("roomId") Long roomId, + @RequestParam(value = "limit", defaultValue = "4") int limit) { + return CommonResponse.success(reportService.getReports(roomId, currentUserId(), limit)); + } + + @PatchMapping("/{reportId}/read") + public CommonResponse markRead(@PathVariable("roomId") Long roomId, + @PathVariable("reportId") Long reportId) { + return CommonResponse.success(reportService.markRead(roomId, reportId, currentUserId())); + } + + private Long currentUserId() { + return (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomDtos.java b/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomDtos.java new file mode 100644 index 00000000..18739ecf --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomDtos.java @@ -0,0 +1,123 @@ +package com.aisip.OnO.backend.studyroom.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +public final class StudyRoomDtos { + + private StudyRoomDtos() { + } + + public record StudyRoomCreateRequest(String name) { + } + + public record StudyRoomUpdateRequest(String name) { + } + + public record StudyRoomJoinRequest(String code) { + } + + public record StudyRoomGoalUpdateRequest(Integer weeklyGoal) { + } + + public record ReactionToggleRequest(String emoji) { + } + + public record ChallengeCreateRequest(String title, String type, String metric, String period, + Integer periodDays, Integer targetValue, + LocalDateTime startAt, LocalDateTime endAt) { + } + + public record SharedProblemCreateRequest(Long problemId, String comment) { + } + + public record SharedProblemCommentRequest(String content) { + } + + public record StudyRoomMemberSummary(Long userId, String profileImageUrl, boolean practicedToday) { + } + + public record StudyRoomListResponse(Long roomId, String name, Long hostUserId, int memberCount, + String thumbnailUrl, boolean hasUnreadReport, + int todayPracticeMemberCount, int todayPracticeCount, + List members) { + } + + public record StudyRoomDetailResponse(Long roomId, String name, Long hostUserId, String thumbnailUrl, + int memberCount, List members) { + } + + public record StudyRoomMemberResponse(Long userId, String name, String profileImageUrl, + Long totalStudyLevel, int currentStreak, + int weeklyProblemCount, int weeklyPracticeCount, + Integer weeklyGoal, Integer goalProgress, + int todayPracticeCount, boolean practicedToday) { + } + + public record StudyRoomThumbnailUpdateResponse(String thumbnailUrl) { + } + + public record InviteCodeResponse(String code, LocalDateTime expiredAt) { + } + + public record GoalUpdateResponse(Integer weeklyGoal, Integer goalProgress) { + } + + public record PageResponse(List content, int page, int size, long totalElements, boolean hasNext) { + } + + public record ReactionResponse(String emoji, long count, boolean reactedByMe) { + } + + public record FeedItemResponse(Long feedId, Long userId, String userName, String userProfileImageUrl, String eventType, + Map metadata, LocalDateTime createdAt, + List reactions) { + } + + public record FeedReactionToggleResponse(Long feedId, List reactions) { + } + + public record ChallengeResponse(Long challengeId, String title, String type, String metric, String period, + Integer periodDays, Integer targetValue, + LocalDateTime startAt, LocalDateTime endAt, String status, + List memberProgress, + Integer groupCurrent) { + } + + public record ChallengeMemberProgressResponse(Long userId, String name, String profileImageUrl, int current, boolean cleared) { + } + + public record SharedProblemResponse(Long sharedProblemId, Long sharedByUserId, String sharedByName, + String sharedByProfileImageUrl, + Long problemId, String problemImageUrl, List problemImageUrls, String reference, + String comment, long commentCount, LocalDateTime sharedAt, + List reactions) { + } + + public record SharedProblemReactionToggleResponse(Long sharedProblemId, List reactions) { + } + + public record SharedProblemCommentReactionToggleResponse(Long commentId, List reactions) { + } + + public record SharedProblemCommentResponse(Long commentId, String content, Long authorId, String authorName, + String authorProfileImageUrl, LocalDateTime createdAt, + LocalDateTime updatedAt, boolean isEdited, boolean isMine, + boolean canDelete, List reactions) { + } + + public record WeeklyReportResponse(Long reportId, LocalDate weekStart, LocalDate weekEnd, + String topMemberName, String topMemberProfileImageUrl, Integer topMemberProblemCount, + String longestStreakName, String longestStreakProfileImageUrl, Integer longestStreakDays, + Integer totalProblems, Integer challengesCompleted, + String cheerMessage, boolean isRead) { + } + + public record WeeklyReportReadResponse(Long reportId, boolean isRead) { + } + + public record ThumbnailUrlUpdateRequest(String thumbnailUrl) { + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomStats.java b/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomStats.java new file mode 100644 index 00000000..c8e52f50 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomStats.java @@ -0,0 +1,4 @@ +package com.aisip.OnO.backend.studyroom.dto; + +public record StudyRoomStats(int weeklyProblemCount, int weeklyPracticeCount, int currentStreak) { +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomTodayPracticeSummary.java b/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomTodayPracticeSummary.java new file mode 100644 index 00000000..53697e3e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomTodayPracticeSummary.java @@ -0,0 +1,8 @@ +package com.aisip.OnO.backend.studyroom.dto; + +public record StudyRoomTodayPracticeSummary(int todayPracticeMemberCount, int todayPracticeCount) { + + public static StudyRoomTodayPracticeSummary empty() { + return new StudyRoomTodayPracticeSummary(0, 0); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoom.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoom.java new file mode 100644 index 00000000..a9c808cf --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoom.java @@ -0,0 +1,64 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room", indexes = { + @Index(name = "idx_study_room_host_user", columnList = "host_user_id") +}) +public class StudyRoom extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Version + private Long version; + + @Column(nullable = false, length = 20) + private String name; + + @Column(name = "host_user_id", nullable = false) + private Long hostUserId; + + @Column(name = "thumbnail_url") + private String thumbnailUrl; + + @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List members = new ArrayList<>(); + + public static StudyRoom create(String name, Long hostUserId) { + return StudyRoom.builder() + .name(name) + .hostUserId(hostUserId) + .members(new ArrayList<>()) + .build(); + } + + public void addMember(StudyRoomMember member) { + members.add(member); + member.updateRoom(this); + } + + public void updateHostUserId(Long hostUserId) { + this.hostUserId = hostUserId; + } + + public void updateName(String name) { + this.name = name; + } + + public void updateThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallenge.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallenge.java new file mode 100644 index 00000000..2a35e04a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallenge.java @@ -0,0 +1,82 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room_challenge", indexes = { + @Index(name = "idx_study_room_challenge_room_status_end", columnList = "room_id, status, end_at") +}) +public class StudyRoomChallenge extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private StudyRoom room; + + @Column(nullable = false, length = 40) + private String title; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private StudyRoomChallengeType type; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 40) + private StudyRoomChallengeMetric metric; + + @Enumerated(EnumType.STRING) + @Column(name = "period", length = 20) + private StudyRoomChallengePeriod period; + + @Column(name = "period_days") + private Integer periodDays; + + @Column(name = "target_value", nullable = false) + private Integer targetValue; + + @Column(name = "start_at", nullable = false) + private LocalDateTime startAt; + + @Column(name = "end_at", nullable = false) + private LocalDateTime endAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private StudyRoomChallengeStatus status; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + public static StudyRoomChallenge create(StudyRoom room, String title, StudyRoomChallengeType type, + StudyRoomChallengeMetric metric, StudyRoomChallengePeriod period, + Integer periodDays, Integer targetValue, + LocalDateTime startAt, LocalDateTime endAt) { + return StudyRoomChallenge.builder() + .room(room) + .title(title) + .type(type) + .metric(metric) + .period(period) + .periodDays(periodDays) + .targetValue(targetValue) + .startAt(startAt) + .endAt(endAt) + .status(StudyRoomChallengeStatus.IN_PROGRESS) + .build(); + } + + public void updateStatus(StudyRoomChallengeStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengeMetric.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengeMetric.java new file mode 100644 index 00000000..ae5431a7 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengeMetric.java @@ -0,0 +1,15 @@ +package com.aisip.OnO.backend.studyroom.entity; + +public enum StudyRoomChallengeMetric { + PROBLEM_COUNT, + PRACTICE_COUNT, + ATTENDANCE, + + // 기존에 저장된 챌린지 역직렬화 호환용 — 신규 생성 시에는 사용하지 않는다. + @Deprecated + WEEKLY_PROBLEM_COUNT, + @Deprecated + WEEKLY_PRACTICE_COUNT, + @Deprecated + STREAK +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengePeriod.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengePeriod.java new file mode 100644 index 00000000..364aa8b2 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengePeriod.java @@ -0,0 +1,7 @@ +package com.aisip.OnO.backend.studyroom.entity; + +public enum StudyRoomChallengePeriod { + DAILY, + WEEKLY, + MONTHLY +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengeStatus.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengeStatus.java new file mode 100644 index 00000000..d4bc9bac --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengeStatus.java @@ -0,0 +1,8 @@ +package com.aisip.OnO.backend.studyroom.entity; + +public enum StudyRoomChallengeStatus { + IN_PROGRESS, + COMPLETED, + FAILED, + EXPIRED +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengeType.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengeType.java new file mode 100644 index 00000000..fa8a5001 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallengeType.java @@ -0,0 +1,7 @@ +package com.aisip.OnO.backend.studyroom.entity; + +public enum StudyRoomChallengeType { + INDIVIDUAL, + GROUP, + STREAK +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomFeed.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomFeed.java new file mode 100644 index 00000000..2660e453 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomFeed.java @@ -0,0 +1,50 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import com.aisip.OnO.backend.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room_feed", indexes = { + @Index(name = "idx_study_room_feed_room_created", columnList = "room_id, created_at"), + @Index(name = "idx_study_room_feed_room_user_type_created", columnList = "room_id, user_id, event_type, created_at") +}) +public class StudyRoomFeed extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private StudyRoom room; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 40) + private StudyRoomFeedEventType eventType; + + @Column(name = "metadata_json", columnDefinition = "TEXT") + private String metadataJson; + + public static StudyRoomFeed create(StudyRoom room, User user, StudyRoomFeedEventType eventType, String metadataJson) { + return StudyRoomFeed.builder() + .room(room) + .user(user) + .eventType(eventType) + .metadataJson(metadataJson) + .build(); + } + + public void updateMetadataJson(String metadataJson) { + this.metadataJson = metadataJson; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomFeedEventType.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomFeedEventType.java new file mode 100644 index 00000000..ba14e4b3 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomFeedEventType.java @@ -0,0 +1,10 @@ +package com.aisip.OnO.backend.studyroom.entity; + +public enum StudyRoomFeedEventType { + PROBLEM_REGISTERED, + PRACTICE_COMPLETED, + STREAK_MILESTONE, + LEVEL_UP, + CHALLENGE_CLEARED, + PROBLEM_SHARED +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomFeedReaction.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomFeedReaction.java new file mode 100644 index 00000000..aac6c897 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomFeedReaction.java @@ -0,0 +1,39 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import com.aisip.OnO.backend.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room_feed_reaction", + uniqueConstraints = @UniqueConstraint(name = "uk_study_room_feed_reaction", columnNames = {"feed_id", "user_id", "emoji"})) +public class StudyRoomFeedReaction extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "feed_id", nullable = false) + private StudyRoomFeed feed; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 80) + private String emoji; + + public static StudyRoomFeedReaction create(StudyRoomFeed feed, User user, String emoji) { + return StudyRoomFeedReaction.builder() + .feed(feed) + .user(user) + .emoji(emoji) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomInviteCode.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomInviteCode.java new file mode 100644 index 00000000..146e595a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomInviteCode.java @@ -0,0 +1,44 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room_invite_code", + uniqueConstraints = @UniqueConstraint(name = "uk_study_room_invite_code", columnNames = "code"), + indexes = @Index(name = "idx_study_room_invite_room", columnList = "room_id")) +public class StudyRoomInviteCode extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private StudyRoom room; + + @Column(nullable = false, length = 6) + private String code; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + public static StudyRoomInviteCode create(StudyRoom room, String code, LocalDateTime expiredAt) { + return StudyRoomInviteCode.builder() + .room(room) + .code(code) + .expiredAt(expiredAt) + .build(); + } + + public boolean isExpired(LocalDateTime now) { + return !expiredAt.isAfter(now); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomMember.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomMember.java new file mode 100644 index 00000000..fc4ed024 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomMember.java @@ -0,0 +1,58 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import com.aisip.OnO.backend.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room_member", + uniqueConstraints = @UniqueConstraint(name = "uk_study_room_member_room_user", columnNames = {"room_id", "user_id"}), + indexes = { + @Index(name = "idx_study_room_member_user", columnList = "user_id"), + @Index(name = "idx_study_room_member_room", columnList = "room_id") + }) +public class StudyRoomMember extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private StudyRoom room; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private StudyRoomMemberRole role; + + @Column(name = "weekly_goal") + private Integer weeklyGoal; + + public static StudyRoomMember create(User user, StudyRoomMemberRole role) { + return StudyRoomMember.builder() + .user(user) + .role(role) + .build(); + } + + public void updateRoom(StudyRoom room) { + this.room = room; + } + + public void updateWeeklyGoal(Integer weeklyGoal) { + this.weeklyGoal = weeklyGoal; + } + + public void promoteToHost() { + this.role = StudyRoomMemberRole.HOST; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomMemberRole.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomMemberRole.java new file mode 100644 index 00000000..29d8aff9 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomMemberRole.java @@ -0,0 +1,6 @@ +package com.aisip.OnO.backend.studyroom.entity; + +public enum StudyRoomMemberRole { + HOST, + MEMBER +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblem.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblem.java new file mode 100644 index 00000000..b3f298a2 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblem.java @@ -0,0 +1,47 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room_shared_problem", indexes = { + @Index(name = "idx_study_room_shared_problem_room_created", columnList = "room_id, created_at"), + @Index(name = "idx_study_room_shared_problem_problem", columnList = "problem_id") +}) +public class StudyRoomSharedProblem extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private StudyRoom room; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shared_by_user_id", nullable = false) + private User sharedByUser; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "problem_id", nullable = false) + private Problem problem; + + @Column(length = 100) + private String comment; + + public static StudyRoomSharedProblem create(StudyRoom room, User sharedByUser, Problem problem, String comment) { + return StudyRoomSharedProblem.builder() + .room(room) + .sharedByUser(sharedByUser) + .problem(problem) + .comment(comment) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblemComment.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblemComment.java new file mode 100644 index 00000000..14707564 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblemComment.java @@ -0,0 +1,45 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import com.aisip.OnO.backend.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room_shared_problem_comment", indexes = { + @Index(name = "idx_shared_problem_comment_problem_id", columnList = "shared_problem_id, id"), + @Index(name = "idx_shared_problem_comment_author", columnList = "author_id") +}) +public class StudyRoomSharedProblemComment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shared_problem_id", nullable = false) + private StudyRoomSharedProblem sharedProblem; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = false) + private User author; + + @Column(nullable = false, length = 1000) + private String content; + + public static StudyRoomSharedProblemComment create(StudyRoomSharedProblem sharedProblem, User author, String content) { + return StudyRoomSharedProblemComment.builder() + .sharedProblem(sharedProblem) + .author(author) + .content(content) + .build(); + } + + public void updateContent(String content) { + this.content = content; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblemCommentReaction.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblemCommentReaction.java new file mode 100644 index 00000000..d540c983 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblemCommentReaction.java @@ -0,0 +1,43 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import com.aisip.OnO.backend.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room_shared_problem_comment_reaction", + uniqueConstraints = @UniqueConstraint(name = "uk_shared_problem_comment_reaction", columnNames = {"comment_id", "user_id", "emoji"}), + indexes = { + @Index(name = "idx_shared_problem_comment_reaction_comment", columnList = "comment_id"), + @Index(name = "idx_shared_problem_comment_reaction_user", columnList = "user_id") + }) +public class StudyRoomSharedProblemCommentReaction extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id", nullable = false) + private StudyRoomSharedProblemComment comment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 80) + private String emoji; + + public static StudyRoomSharedProblemCommentReaction create(StudyRoomSharedProblemComment comment, User user, String emoji) { + return StudyRoomSharedProblemCommentReaction.builder() + .comment(comment) + .user(user) + .emoji(emoji) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblemReaction.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblemReaction.java new file mode 100644 index 00000000..bbd2fac5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomSharedProblemReaction.java @@ -0,0 +1,39 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import com.aisip.OnO.backend.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room_shared_problem_reaction", + uniqueConstraints = @UniqueConstraint(name = "uk_study_room_shared_problem_reaction", columnNames = {"shared_problem_id", "user_id", "emoji"})) +public class StudyRoomSharedProblemReaction extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shared_problem_id", nullable = false) + private StudyRoomSharedProblem sharedProblem; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 80) + private String emoji; + + public static StudyRoomSharedProblemReaction create(StudyRoomSharedProblem sharedProblem, User user, String emoji) { + return StudyRoomSharedProblemReaction.builder() + .sharedProblem(sharedProblem) + .user(user) + .emoji(emoji) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomWeeklyReport.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomWeeklyReport.java new file mode 100644 index 00000000..ad00a44c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomWeeklyReport.java @@ -0,0 +1,80 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room_weekly_report", + uniqueConstraints = @UniqueConstraint(name = "uk_study_room_weekly_report", columnNames = {"room_id", "week_start"})) +public class StudyRoomWeeklyReport extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private StudyRoom room; + + @Column(name = "week_start", nullable = false) + private LocalDate weekStart; + + @Column(name = "week_end", nullable = false) + private LocalDate weekEnd; + + @Column(name = "top_member_name") + private String topMemberName; + + @Column(name = "top_member_problem_count", nullable = false) + private Integer topMemberProblemCount; + + @Column(name = "longest_streak_name") + private String longestStreakName; + + @Column(name = "longest_streak_days", nullable = false) + private Integer longestStreakDays; + + @Column(name = "top_member_profile_image_url", length = 512) + private String topMemberProfileImageUrl; + + @Column(name = "longest_streak_profile_image_url", length = 512) + private String longestStreakProfileImageUrl; + + @Column(name = "total_problems", nullable = false) + private Integer totalProblems; + + @Column(name = "challenges_completed", nullable = false) + private Integer challengesCompleted; + + @Column(name = "cheer_message", nullable = false) + private String cheerMessage; + + public static StudyRoomWeeklyReport create(StudyRoom room, LocalDate weekStart, LocalDate weekEnd, + String topMemberName, String topMemberProfileImageUrl, + int topMemberProblemCount, + String longestStreakName, String longestStreakProfileImageUrl, + int longestStreakDays, + int totalProblems, int challengesCompleted, String cheerMessage) { + return StudyRoomWeeklyReport.builder() + .room(room) + .weekStart(weekStart) + .weekEnd(weekEnd) + .topMemberName(topMemberName) + .topMemberProfileImageUrl(topMemberProfileImageUrl) + .topMemberProblemCount(topMemberProblemCount) + .longestStreakName(longestStreakName) + .longestStreakProfileImageUrl(longestStreakProfileImageUrl) + .longestStreakDays(longestStreakDays) + .totalProblems(totalProblems) + .challengesCompleted(challengesCompleted) + .cheerMessage(cheerMessage) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomWeeklyReportRead.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomWeeklyReportRead.java new file mode 100644 index 00000000..72303507 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomWeeklyReportRead.java @@ -0,0 +1,41 @@ +package com.aisip.OnO.backend.studyroom.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import com.aisip.OnO.backend.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "study_room_weekly_report_read", + uniqueConstraints = @UniqueConstraint(name = "uk_study_room_weekly_report_read", columnNames = {"report_id", "user_id"})) +public class StudyRoomWeeklyReportRead extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "report_id", nullable = false) + private StudyRoomWeeklyReport report; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "read_at", nullable = false) + private LocalDateTime readAt; + + public static StudyRoomWeeklyReportRead create(StudyRoomWeeklyReport report, User user, LocalDateTime readAt) { + return StudyRoomWeeklyReportRead.builder() + .report(report) + .user(user) + .readAt(readAt) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/event/StudyRoomActivityEvent.java b/src/main/java/com/aisip/OnO/backend/studyroom/event/StudyRoomActivityEvent.java new file mode 100644 index 00000000..edd174cc --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/event/StudyRoomActivityEvent.java @@ -0,0 +1,8 @@ +package com.aisip.OnO.backend.studyroom.event; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedEventType; + +import java.util.Map; + +public record StudyRoomActivityEvent(Long userId, StudyRoomFeedEventType eventType, Map metadata) { +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCase.java b/src/main/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCase.java new file mode 100644 index 00000000..3526e538 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCase.java @@ -0,0 +1,35 @@ +package com.aisip.OnO.backend.studyroom.exception; + +import com.aisip.OnO.backend.common.exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum StudyRoomErrorCase implements ErrorCase { + + STUDY_ROOM_NOT_FOUND(404, 10001, "스터디룸을 찾을 수 없습니다."), + STUDY_ROOM_FORBIDDEN(403, 10002, "스터디룸 멤버만 접근할 수 있습니다."), + STUDY_ROOM_HOST_ONLY(403, 10003, "스터디룸 방장만 사용할 수 있습니다."), + STUDY_ROOM_FULL(409, 10004, "스터디룸 최대 인원을 초과했습니다."), + STUDY_ROOM_LIMIT_EXCEEDED(409, 10005, "참여 가능한 스터디룸 수를 초과했습니다."), + INVITE_CODE_INVALID(400, 10006, "초대 코드가 유효하지 않습니다."), + INVITE_CODE_EXPIRED(400, 10007, "초대 코드가 만료되었습니다."), + ALREADY_MEMBER(409, 10008, "이미 참여 중인 스터디룸입니다."), + CHALLENGE_NOT_FOUND(404, 10009, "챌린지를 찾을 수 없습니다."), + CHALLENGE_LIMIT_EXCEEDED(409, 10010, "진행 중인 챌린지 수를 초과했습니다."), + SESSION_ALREADY_ACTIVE(409, 10011, "이미 진행 중인 공부 세션이 있습니다."), + SESSION_NOT_FOUND(404, 10012, "공부 세션을 찾을 수 없습니다."), + SHARED_PROBLEM_NOT_FOUND(404, 10013, "공유 문제를 찾을 수 없습니다."), + REPORT_NOT_FOUND(404, 10014, "주간 리포트를 찾을 수 없습니다."), + INVALID_REACTION_EMOJI(400, 10015, "허용되지 않은 반응입니다."), + INVALID_STUDY_ROOM_REQUEST(400, 10016, "스터디룸 요청 값이 올바르지 않습니다."), + SHARED_PROBLEM_COMMENT_NOT_FOUND(404, 10017, "공유 문제 댓글을 찾을 수 없습니다."), + INVALID_SHARED_PROBLEM_COMMENT(400, 10018, "공유 문제 댓글 내용이 올바르지 않습니다."), + SHARED_PROBLEM_COMMENT_FORBIDDEN(403, 10019, "공유 문제 댓글에 대한 권한이 없습니다."), + ALREADY_SHARED_PROBLEM(409, 10020, "이미 공유된 문제입니다."); + + private final Integer httpStatusCode; + private final Integer errorCode; + private final String message; +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationJob.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationJob.java new file mode 100644 index 00000000..4eb761e7 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationJob.java @@ -0,0 +1,50 @@ +package com.aisip.OnO.backend.studyroom.quartz; + +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.Job; +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChallengeNotificationJob implements Job { + + private final StudyRoomMemberRepository memberRepository; + private final FcmService fcmService; + + @Override + public void execute(JobExecutionContext context) { + JobDataMap dataMap = context.getMergedJobDataMap(); + Long roomId = Long.parseLong(dataMap.getString("roomId")); + String challengeTitle = dataMap.getString("challengeTitle"); + String notificationType = dataMap.getString("notificationType"); + + String title, body; + if ("HALFWAY".equals(notificationType)) { + title = "챌린지 중간 알림"; + body = "'" + challengeTitle + "' 챌린지가 절반 지났어요! 목표를 향해 달려가세요."; + } else { + title = "챌린지 마감 D-1"; + body = "'" + challengeTitle + "' 챌린지가 내일 마감돼요. 마무리 스퍼트!"; + } + + NotificationRequestDto dto = new NotificationRequestDto(null, title, body, + Map.of("type", "CHALLENGE_NOTIFICATION", "roomId", String.valueOf(roomId))); + + memberRepository.findAllWithUserByRoomId(roomId).forEach(member -> { + try { + fcmService.sendNotificationToAllUserDevice(member.getUser().getId(), dto); + } catch (Exception e) { + log.warn("챌린지 알림 발송 실패 - userId: {}", member.getUser().getId(), e); + } + }); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationScheduler.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationScheduler.java new file mode 100644 index 00000000..5f3b15f5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationScheduler.java @@ -0,0 +1,80 @@ +package com.aisip.OnO.backend.studyroom.quartz; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallenge; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChallengeNotificationScheduler { + + private static final String GROUP = "CHALLENGE_NOTIFICATION"; + private static final ZoneId ZONE = ZoneId.of("Asia/Seoul"); + + private final Scheduler scheduler; + + public void scheduleNotifications(StudyRoomChallenge challenge) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime startAt = challenge.getStartAt(); + LocalDateTime endAt = challenge.getEndAt(); + + LocalDateTime halfwayAt = startAt.plus(Duration.between(startAt, endAt).dividedBy(2)); + if (halfwayAt.isAfter(now)) { + schedule(challenge, "HALFWAY", halfwayAt); + } + + LocalDateTime oneDayBeforeAt = endAt.minusDays(1); + if (oneDayBeforeAt.isAfter(now)) { + schedule(challenge, "ONE_DAY_LEFT", oneDayBeforeAt); + } + } + + public void cancelNotifications(Long challengeId) { + cancelJob(challengeId, "HALFWAY"); + cancelJob(challengeId, "ONE_DAY_LEFT"); + } + + private void schedule(StudyRoomChallenge challenge, String type, LocalDateTime fireAt) { + try { + String key = "challenge-" + challenge.getId() + "-" + type; + JobDataMap dataMap = new JobDataMap(); + dataMap.put("roomId", String.valueOf(challenge.getRoom().getId())); + dataMap.put("challengeTitle", challenge.getTitle()); + dataMap.put("notificationType", type); + + JobDetail jobDetail = JobBuilder.newJob(ChallengeNotificationJob.class) + .withIdentity(key, GROUP) + .usingJobData(dataMap) + .build(); + + Trigger trigger = TriggerBuilder.newTrigger() + .withIdentity(key, GROUP) + .startAt(Date.from(fireAt.atZone(ZONE).toInstant())) + .withSchedule(SimpleScheduleBuilder.simpleSchedule()) + .build(); + + scheduler.scheduleJob(jobDetail, trigger); + log.info("챌린지 알림 등록 - challengeId: {}, type: {}, fireAt: {}", challenge.getId(), type, fireAt); + } catch (SchedulerException e) { + log.warn("챌린지 알림 등록 실패 - challengeId: {}, type: {}", challenge.getId(), type, e); + } + } + + private void cancelJob(Long challengeId, String type) { + try { + String key = "challenge-" + challengeId + "-" + type; + scheduler.unscheduleJob(TriggerKey.triggerKey(key, GROUP)); + scheduler.deleteJob(JobKey.jobKey(key, GROUP)); + } catch (SchedulerException e) { + log.warn("챌린지 알림 취소 실패 - challengeId: {}, type: {}", challengeId, type, e); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomScheduler.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomScheduler.java new file mode 100644 index 00000000..481efa2c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomScheduler.java @@ -0,0 +1,27 @@ +package com.aisip.OnO.backend.studyroom.quartz; + +import org.quartz.*; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class StudyRoomScheduler { + + @Bean + public JobDetail studyRoomWeeklyReportJobDetail() { + return JobBuilder.newJob(StudyRoomWeeklyReportJob.class) + .withIdentity("studyRoomWeeklyReportJob") + .storeDurably() + .build(); + } + + @Bean + public Trigger studyRoomWeeklyReportTrigger() { + return TriggerBuilder.newTrigger() + .forJob(studyRoomWeeklyReportJobDetail()) + .withIdentity("studyRoomWeeklyReportTrigger") + .withSchedule(CronScheduleBuilder.cronSchedule("0 0 8 ? * MON").inTimeZone(java.util.TimeZone.getTimeZone("Asia/Seoul"))) + .build(); + } + +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomWeeklyReportJob.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomWeeklyReportJob.java new file mode 100644 index 00000000..041b9da5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomWeeklyReportJob.java @@ -0,0 +1,19 @@ +package com.aisip.OnO.backend.studyroom.quartz; + +import com.aisip.OnO.backend.studyroom.service.StudyRoomWeeklyReportService; +import lombok.RequiredArgsConstructor; +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class StudyRoomWeeklyReportJob implements Job { + + private final StudyRoomWeeklyReportService reportService; + + @Override + public void execute(JobExecutionContext context) { + reportService.createPreviousWeekReports(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomChallengeRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomChallengeRepository.java new file mode 100644 index 00000000..775948e3 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomChallengeRepository.java @@ -0,0 +1,53 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallenge; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallengeStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface StudyRoomChallengeRepository extends JpaRepository { + + List findAllByRoomIdOrderByEndAtAsc(Long roomId); + + @Query("select c from StudyRoomChallenge c " + + "where c.room.id = :roomId and c.status = :status and c.endAt >= :now " + + "order by c.endAt asc") + List findActiveByRoomId(@Param("roomId") Long roomId, + @Param("status") StudyRoomChallengeStatus status, + @Param("now") LocalDateTime now); + + Optional findByIdAndRoomId(Long id, Long roomId); + + long countByRoomIdAndStatus(Long roomId, StudyRoomChallengeStatus status); + + @Query("select c from StudyRoomChallenge c where c.room.id in :roomIds and c.status = :status") + List findAllByRoomIdsAndStatus(@Param("roomIds") Collection roomIds, + @Param("status") StudyRoomChallengeStatus status); + + @Query("select c.room.id, count(c.id) from StudyRoomChallenge c " + + "where c.room.id in :roomIds and c.status = :status and c.endAt between :start and :end " + + "group by c.room.id") + List countByRoomIdsAndStatusAndEndAtBetween(@Param("roomIds") Collection roomIds, + @Param("status") StudyRoomChallengeStatus status, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** + * IN_PROGRESS 상태인 챌린지를 원자적으로 targetStatus로 전이. + * 동시 호출 시 단 하나의 스레드만 1을 반환 — FCM 중복 발송 방지에 사용. + */ + @Modifying + @Query("update StudyRoomChallenge c set c.status = :targetStatus, c.completedAt = :now " + + "where c.id = :id and c.status = 'IN_PROGRESS'") + int tryTransitionFromInProgress(@Param("id") Long id, + @Param("targetStatus") StudyRoomChallengeStatus targetStatus, + @Param("now") LocalDateTime now); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedReactionRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedReactionRepository.java new file mode 100644 index 00000000..d7affac2 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedReactionRepository.java @@ -0,0 +1,20 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedReaction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface StudyRoomFeedReactionRepository extends JpaRepository { + + Optional findByFeedIdAndUserIdAndEmoji(Long feedId, Long userId, String emoji); + + @Query("select r from StudyRoomFeedReaction r where r.feed.id in :feedIds") + List findAllByFeedIds(@Param("feedIds") Collection feedIds); + + List findAllByFeedId(Long feedId); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedRepository.java new file mode 100644 index 00000000..287fc0fe --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedRepository.java @@ -0,0 +1,31 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeed; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedEventType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface StudyRoomFeedRepository extends JpaRepository { + + @Query("select f from StudyRoomFeed f join fetch f.user where f.room.id = :roomId order by f.createdAt desc") + Page findPageWithUserByRoomId(@Param("roomId") Long roomId, Pageable pageable); + + @Query("select f from StudyRoomFeed f join fetch f.user " + + "where f.room.id = :roomId and (:cursor is null or f.id < :cursor) " + + "order by f.id desc") + List findWithUserByRoomIdAndCursor(@Param("roomId") Long roomId, + @Param("cursor") Long cursor, + Pageable pageable); + + Optional findByIdAndRoomId(Long id, Long roomId); + + Optional findTopByRoomIdAndUserIdAndEventTypeAndCreatedAtBetweenOrderByCreatedAtDesc( + Long roomId, Long userId, StudyRoomFeedEventType eventType, LocalDateTime start, LocalDateTime end); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomInviteCodeRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomInviteCodeRepository.java new file mode 100644 index 00000000..aff71efc --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomInviteCodeRepository.java @@ -0,0 +1,23 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomInviteCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface StudyRoomInviteCodeRepository extends JpaRepository { + + Optional findTopByRoomIdOrderByExpiredAtDesc(Long roomId); + + Optional findByCode(String code); + + boolean existsByCode(String code); + + @Modifying + @Query("delete from StudyRoomInviteCode c where c.expiredAt < :threshold") + void deleteExpiredBefore(@Param("threshold") LocalDateTime threshold); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomMemberRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomMemberRepository.java new file mode 100644 index 00000000..7ea849ea --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomMemberRepository.java @@ -0,0 +1,48 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMemberRole; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.Collection; + +public interface StudyRoomMemberRepository extends JpaRepository { + + Optional findByRoomIdAndUserId(Long roomId, Long userId); + + boolean existsByRoomIdAndUserId(Long roomId, Long userId); + + long countByRoomId(Long roomId); + + long countByUserId(Long userId); + + long countByRoomIdAndRole(Long roomId, StudyRoomMemberRole role); + + List findAllByUserIdOrderByCreatedAtDesc(Long userId); + + @Query("select m from StudyRoomMember m join fetch m.user where m.room.id = :roomId order by m.createdAt asc") + List findAllWithUserByRoomId(@Param("roomId") Long roomId); + + @Query("select m from StudyRoomMember m join fetch m.room join fetch m.user where m.room.id in :roomIds order by m.room.id asc, m.createdAt asc") + List findAllWithRoomAndUserByRoomIds(@Param("roomIds") Collection roomIds); + + @Query("select m from StudyRoomMember m join fetch m.room where m.user.id = :userId order by m.createdAt desc") + List findAllWithRoomByUserId(@Param("userId") Long userId); + + @Query(""" + select m.room.id, count(m.id) + from StudyRoomMember m + where m.room.id in :roomIds + group by m.room.id + """) + List countMembersByRoomIds(@Param("roomIds") Collection roomIds); + + @Modifying + @Query("delete from StudyRoomMember m where m.room.id = :roomId and m.user.id = :userId") + void deleteByRoomIdAndUserId(@Param("roomId") Long roomId, @Param("userId") Long userId); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomRepository.java new file mode 100644 index 00000000..a3074d19 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomRepository.java @@ -0,0 +1,22 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface StudyRoomRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select r from StudyRoom r where r.id = :roomId") + Optional findByIdForUpdate(@Param("roomId") Long roomId); + + @Query("select r from StudyRoom r where r.id > :cursor order by r.id asc") + List findBatchAfterId(@Param("cursor") Long cursor, Pageable pageable); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentReactionRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentReactionRepository.java new file mode 100644 index 00000000..a89fb012 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentReactionRepository.java @@ -0,0 +1,38 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemCommentReaction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface StudyRoomSharedProblemCommentReactionRepository extends JpaRepository { + + Optional findByCommentIdAndUserIdAndEmoji(Long commentId, Long userId, String emoji); + + @Query("select r from StudyRoomSharedProblemCommentReaction r where r.comment.id = :commentId") + List findAllByCommentId(@Param("commentId") Long commentId); + + @Query("select r from StudyRoomSharedProblemCommentReaction r where r.comment.id in :commentIds") + List findAllByCommentIds(@Param("commentIds") Collection commentIds); + + @Modifying + @Query("delete from StudyRoomSharedProblemCommentReaction r where r.user.id = :userId") + void deleteByUserId(@Param("userId") Long userId); + + @Modifying + @Query("delete from StudyRoomSharedProblemCommentReaction r where r.comment.id = :commentId") + void deleteByCommentId(@Param("commentId") Long commentId); + + @Modifying + @Query("delete from StudyRoomSharedProblemCommentReaction r where r.comment.sharedProblem.id = :sharedProblemId") + void deleteBySharedProblemId(@Param("sharedProblemId") Long sharedProblemId); + + @Modifying + @Query("delete from StudyRoomSharedProblemCommentReaction r where r.comment.author.id = :authorId") + void deleteByCommentAuthorId(@Param("authorId") Long authorId); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentRepository.java new file mode 100644 index 00000000..66077287 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentRepository.java @@ -0,0 +1,59 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemComment; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.Collection; + +public interface StudyRoomSharedProblemCommentRepository extends JpaRepository { + + @Query(""" + select c + from StudyRoomSharedProblemComment c + join fetch c.sharedProblem sp + join fetch sp.room + join fetch c.author + where c.sharedProblem.id = :sharedProblemId + and (:cursor is null or c.id < :cursor) + order by c.id desc + """) + List findBySharedProblemIdAndCursor(@Param("sharedProblemId") Long sharedProblemId, + @Param("cursor") Long cursor, + Pageable pageable); + + @Query(""" + select c + from StudyRoomSharedProblemComment c + join fetch c.author + join fetch c.sharedProblem sp + join fetch sp.room + where c.id = :commentId + and sp.id = :sharedProblemId + and sp.room.id = :roomId + """) + Optional findByIdAndSharedProblemIdAndRoomId(@Param("commentId") Long commentId, + @Param("sharedProblemId") Long sharedProblemId, + @Param("roomId") Long roomId); + + @Query(""" + select c.sharedProblem.id, count(c.id) + from StudyRoomSharedProblemComment c + where c.sharedProblem.id in :sharedProblemIds + group by c.sharedProblem.id + """) + List countBySharedProblemIds(@Param("sharedProblemIds") Collection sharedProblemIds); + + @Modifying + @Query("delete from StudyRoomSharedProblemComment c where c.sharedProblem.id = :sharedProblemId") + void deleteBySharedProblemId(@Param("sharedProblemId") Long sharedProblemId); + + @Modifying + @Query("delete from StudyRoomSharedProblemComment c where c.author.id = :authorId") + void deleteByAuthorId(@Param("authorId") Long authorId); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemReactionRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemReactionRepository.java new file mode 100644 index 00000000..31d83f99 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemReactionRepository.java @@ -0,0 +1,25 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemReaction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface StudyRoomSharedProblemReactionRepository extends JpaRepository { + + Optional findBySharedProblemIdAndUserIdAndEmoji(Long sharedProblemId, Long userId, String emoji); + + @Query("select r from StudyRoomSharedProblemReaction r where r.sharedProblem.id in :sharedProblemIds") + List findAllBySharedProblemIds(@Param("sharedProblemIds") Collection sharedProblemIds); + + List findAllBySharedProblemId(Long sharedProblemId); + + @Modifying + @Query("delete from StudyRoomSharedProblemReaction r where r.sharedProblem.id = :sharedProblemId") + void deleteBySharedProblemId(@Param("sharedProblemId") Long sharedProblemId); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemRepository.java new file mode 100644 index 00000000..06587ad4 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemRepository.java @@ -0,0 +1,28 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblem; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface StudyRoomSharedProblemRepository extends JpaRepository { + + @Query("select sp from StudyRoomSharedProblem sp join fetch sp.sharedByUser join fetch sp.problem where sp.room.id = :roomId order by sp.createdAt desc") + Page findPageByRoomId(@Param("roomId") Long roomId, Pageable pageable); + + @Query("select sp from StudyRoomSharedProblem sp join fetch sp.sharedByUser join fetch sp.problem " + + "where sp.room.id = :roomId and (:cursor is null or sp.id < :cursor) " + + "order by sp.id desc") + List findByRoomIdAndCursor(@Param("roomId") Long roomId, + @Param("cursor") Long cursor, + Pageable pageable); + + Optional findByIdAndRoomId(Long id, Long roomId); + + boolean existsByRoomIdAndProblemId(Long roomId, Long problemId); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomWeeklyReportReadRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomWeeklyReportReadRepository.java new file mode 100644 index 00000000..18e57534 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomWeeklyReportReadRepository.java @@ -0,0 +1,24 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomWeeklyReportRead; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface StudyRoomWeeklyReportReadRepository extends JpaRepository { + + boolean existsByReportIdAndUserId(Long reportId, Long userId); + + Optional findByReportIdAndUserId(Long reportId, Long userId); + + @Query("select r from StudyRoomWeeklyReportRead r where r.report.id in :reportIds and r.user.id = :userId") + List findAllByReportIdsAndUserId(@Param("reportIds") Collection reportIds, @Param("userId") Long userId); + + @Query("select count(rp) > 0 from StudyRoomWeeklyReport rp where rp.room.id = :roomId and not exists " + + "(select rr.id from StudyRoomWeeklyReportRead rr where rr.report = rp and rr.user.id = :userId)") + boolean existsUnreadReport(@Param("roomId") Long roomId, @Param("userId") Long userId); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomWeeklyReportRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomWeeklyReportRepository.java new file mode 100644 index 00000000..7b915fb5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomWeeklyReportRepository.java @@ -0,0 +1,18 @@ +package com.aisip.OnO.backend.studyroom.repository; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoomWeeklyReport; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface StudyRoomWeeklyReportRepository extends JpaRepository { + + boolean existsByRoomIdAndWeekStart(Long roomId, LocalDate weekStart); + + Optional findByIdAndRoomId(Long id, Long roomId); + + List findAllByRoomIdOrderByWeekStartDesc(Long roomId, Pageable pageable); +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomAccessService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomAccessService.java new file mode 100644 index 00000000..88b91373 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomAccessService.java @@ -0,0 +1,43 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMemberRole; +import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StudyRoomAccessService { + + private final StudyRoomRepository roomRepository; + private final StudyRoomMemberRepository memberRepository; + + public StudyRoom getRoomOrThrow(Long roomId) { + return roomRepository.findById(roomId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_NOT_FOUND)); + } + + public StudyRoomMember getMemberOrThrow(Long roomId, Long userId) { + getRoomOrThrow(roomId); + return memberRepository.findByRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_FORBIDDEN)); + } + + public void validateMember(Long roomId, Long userId) { + getMemberOrThrow(roomId, userId); + } + + public void validateHost(Long roomId, Long userId) { + StudyRoomMember member = getMemberOrThrow(roomId, userId); + if (member.getRole() != StudyRoomMemberRole.HOST) { + throw new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_HOST_ONLY); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java new file mode 100644 index 00000000..0ee020b5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java @@ -0,0 +1,367 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomStats; +import com.aisip.OnO.backend.studyroom.entity.*; +import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; +import com.aisip.OnO.backend.studyroom.quartz.ChallengeNotificationScheduler; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomChallengeRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.transaction.annotation.Propagation; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudyRoomChallengeService { + + private static final int MAX_IN_PROGRESS_CHALLENGE_COUNT = 5; + + private final StudyRoomAccessService accessService; + private final StudyRoomChallengeRepository challengeRepository; + private final StudyRoomMemberRepository memberRepository; + private final StudyRoomStatsService statsService; + private final ChallengeNotificationScheduler notificationScheduler; + private final FcmService fcmService; + + @Transactional + public List getChallenges(Long roomId, Long userId) { + accessService.validateMember(roomId, userId); + List members = memberRepository.findAllWithUserByRoomId(roomId); + return challengeRepository.findAllByRoomIdOrderByEndAtAsc(roomId).stream() + .map(challenge -> toResponse(challenge, members, true)) + .sorted(Comparator.comparing((ChallengeResponse r) -> "in_progress".equals(r.status()) ? 0 : 1) + .thenComparing(Comparator.comparing(ChallengeResponse::endAt).reversed())) + .toList(); + } + + @Transactional + public ChallengeResponse createChallenge(Long roomId, Long userId, ChallengeCreateRequest request) { + accessService.validateMember(roomId, userId); + validateCreateRequest(request); + refreshRoomChallengeStatuses(roomId); + if (challengeRepository.countByRoomIdAndStatus(roomId, StudyRoomChallengeStatus.IN_PROGRESS) >= MAX_IN_PROGRESS_CHALLENGE_COUNT) { + throw new ApplicationException(StudyRoomErrorCase.CHALLENGE_LIMIT_EXCEEDED); + } + LocalDateTime startAt = request.startAt() == null ? LocalDateTime.now() : request.startAt(); + StudyRoomChallengePeriod period = request.period() == null + ? null + : parseEnum(request.period(), StudyRoomChallengePeriod.class); + StudyRoom room = accessService.getRoomOrThrow(roomId); + StudyRoomChallenge challenge = challengeRepository.save(StudyRoomChallenge.create( + room, + request.title().trim(), + parseEnum(request.type(), StudyRoomChallengeType.class), + parseEnum(request.metric(), StudyRoomChallengeMetric.class), + period, + period == null ? request.periodDays() : null, + request.targetValue(), + startAt, + request.endAt() + )); + notificationScheduler.scheduleNotifications(challenge); + return toResponse(challenge, memberRepository.findAllWithUserByRoomId(roomId), true); + } + + @Transactional + public void deleteChallenge(Long roomId, Long challengeId, Long userId) { + accessService.validateHost(roomId, userId); + StudyRoomChallenge challenge = challengeRepository.findByIdAndRoomId(challengeId, roomId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.CHALLENGE_NOT_FOUND)); + notificationScheduler.cancelNotifications(challengeId); + challengeRepository.delete(challenge); + } + + private void refreshRoomChallengeStatuses(Long roomId) { + List members = memberRepository.findAllWithUserByRoomId(roomId); + challengeRepository.findAllByRoomIdOrderByEndAtAsc(roomId) + .forEach(challenge -> toResponse(challenge, members, true)); + } + + private ChallengeResponse toResponse(StudyRoomChallenge challenge, List members, boolean persistStatus) { + List userIds = members.stream().map(member -> member.getUser().getId()).toList(); + AggregationRange range = resolveAggregationRange(challenge, LocalDateTime.now()); + LocalDate streakBaseDate = min(LocalDate.now(), challenge.getEndAt().toLocalDate()); + Map streaks = statsService.currentStreaks(userIds, streakBaseDate, challenge.getStartAt().toLocalDate()); + Map stats = statsService.getStats(userIds, range.start(), range.end(), streaks); + Map attendanceDays = isAttendanceMetric(challenge.getMetric()) + ? statsService.attendanceDayCounts(userIds, range.start(), range.end()) + : Map.of(); + List memberProgress = challenge.getType() == StudyRoomChallengeType.GROUP + ? List.of() + : members.stream() + .map(member -> { + int current = metricValue(challenge.getMetric(), stats.get(member.getUser().getId()), + attendanceDays.getOrDefault(member.getUser().getId(), 0)); + return new ChallengeMemberProgressResponse(member.getUser().getId(), member.getUser().getName(), + member.getUser().getProfileImageUrl(), current, current >= challenge.getTargetValue()); + }) + .toList(); + Integer groupCurrent = challenge.getType() == StudyRoomChallengeType.GROUP + ? userIds.stream() + .mapToInt(memberId -> metricValue(challenge.getMetric(), stats.get(memberId), + attendanceDays.getOrDefault(memberId, 0))) + .sum() + : null; + StudyRoomChallengeStatus status = effectiveStatus(challenge, memberProgress, groupCurrent); + if (persistStatus && challenge.getStatus() != status) { + if (status == StudyRoomChallengeStatus.COMPLETED) { + // 원자적 UPDATE WHERE status = 'IN_PROGRESS' — 단 하나의 스레드만 1을 반환해 FCM 중복 발송 방지 + int updated = challengeRepository.tryTransitionFromInProgress(challenge.getId(), status, LocalDateTime.now()); + if (updated > 0) { + challenge.updateStatus(status); + // 트랜잭션 커밋 후 FCM 발송 — 롤백 시 중복 발송 방지 + final StudyRoomChallenge committedChallenge = challenge; + final List committedMembers = members; + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + notifyChallengeCompleted(committedChallenge, committedMembers); + } + }); + } + } else { + challenge.updateStatus(status); + } + } + return new ChallengeResponse( + challenge.getId(), + challenge.getTitle(), + toApiValue(challenge.getType().name()), + toApiValue(challenge.getMetric().name()), + challenge.getPeriod() == null ? null : toApiValue(challenge.getPeriod().name()), + challenge.getPeriodDays(), + challenge.getTargetValue(), + challenge.getStartAt(), + challenge.getEndAt(), + toApiValue(status.name()), + memberProgress, + groupCurrent + ); + } + + private StudyRoomChallengeStatus effectiveStatus(StudyRoomChallenge challenge, + List memberProgress, + Integer groupCurrent) { + if (challenge.getStatus() != StudyRoomChallengeStatus.IN_PROGRESS) { + return challenge.getStatus(); + } + if (challenge.getPeriod() == null && challenge.getPeriodDays() == null) { + boolean completed = challenge.getType() == StudyRoomChallengeType.GROUP + ? groupCurrent != null && groupCurrent >= challenge.getTargetValue() + : !memberProgress.isEmpty() && memberProgress.stream().allMatch(ChallengeMemberProgressResponse::cleared); + if (completed) { + return StudyRoomChallengeStatus.COMPLETED; + } + } + if (challenge.getEndAt().isBefore(LocalDateTime.now())) { + return StudyRoomChallengeStatus.EXPIRED; + } + return StudyRoomChallengeStatus.IN_PROGRESS; + } + + private int metricValue(StudyRoomChallengeMetric metric, StudyRoomStats stats, int attendanceDays) { + if (stats == null) { + return 0; + } + return switch (metric) { + case PROBLEM_COUNT, WEEKLY_PROBLEM_COUNT -> stats.weeklyProblemCount(); + case PRACTICE_COUNT, WEEKLY_PRACTICE_COUNT -> stats.weeklyPracticeCount(); + case ATTENDANCE, STREAK -> attendanceDays; + }; + } + + private boolean isAttendanceMetric(StudyRoomChallengeMetric metric) { + return metric == StudyRoomChallengeMetric.ATTENDANCE || metric == StudyRoomChallengeMetric.STREAK; + } + + private AggregationRange resolveAggregationRange(StudyRoomChallenge challenge, LocalDateTime now) { + LocalDateTime startAt = challenge.getStartAt(); + LocalDateTime endAt = challenge.getEndAt(); + StudyRoomChallengePeriod period = challenge.getPeriod(); + Integer periodDays = challenge.getPeriodDays(); + if (period == null && periodDays == null) { + return new AggregationRange(startAt, endAt); + } + LocalDateTime anchor = clamp(now, startAt, endAt); + LocalDateTime windowStart = startAt; + LocalDateTime windowEnd = addPeriod(windowStart, period, periodDays); + while (!windowEnd.isAfter(anchor) && windowEnd.isBefore(endAt)) { + windowStart = windowEnd; + windowEnd = addPeriod(windowStart, period, periodDays); + } + return new AggregationRange(windowStart, windowEnd.isAfter(endAt) ? endAt : windowEnd); + } + + private LocalDateTime addPeriod(LocalDateTime base, StudyRoomChallengePeriod period, Integer periodDays) { + if (period != null) { + return switch (period) { + case DAILY -> base.plusDays(1); + case WEEKLY -> base.plusDays(7); + case MONTHLY -> base.plusMonths(1); + }; + } + return base.plusDays(periodDays); + } + + private LocalDateTime clamp(LocalDateTime value, LocalDateTime lower, LocalDateTime upper) { + if (value.isBefore(lower)) { + return lower; + } + if (value.isAfter(upper)) { + return upper; + } + return value; + } + + private record AggregationRange(LocalDateTime start, LocalDateTime end) {} + + private void validateCreateRequest(ChallengeCreateRequest request) { + if (request.title() == null || request.title().isBlank() || request.title().length() > 40 + || request.targetValue() == null || request.targetValue() < 1 + || request.endAt() == null || !request.endAt().isAfter(LocalDateTime.now()) + || request.startAt() != null && !request.startAt().isBefore(request.endAt()) + || request.period() != null && request.periodDays() != null + || request.periodDays() != null && request.periodDays() < 1) { + throw new ApplicationException(StudyRoomErrorCase.INVALID_STUDY_ROOM_REQUEST); + } + } + + /** + * 사용자의 학습 활동 이후 해당 사용자가 속한 룸들의 IN_PROGRESS 챌린지 완료 여부를 즉시 확인합니다. + * REQUIRES_NEW로 독립 트랜잭션 실행 — 실패해도 피드 생성 트랜잭션에 영향 없음. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void checkAndNotifyChallengeCompletionForUser(Long userId) { + List memberships = memberRepository.findAllWithRoomByUserId(userId); + if (memberships.isEmpty()) { + return; + } + + List roomIds = memberships.stream() + .map(m -> m.getRoom().getId()) + .toList(); + List inProgressChallenges = + challengeRepository.findAllByRoomIdsAndStatus(roomIds, StudyRoomChallengeStatus.IN_PROGRESS); + if (inProgressChallenges.isEmpty()) { + return; + } + + Map> membersByRoom = new HashMap<>(); + Map statsCache = new HashMap<>(); + for (StudyRoomChallenge challenge : inProgressChallenges) { + Long roomId = challenge.getRoom().getId(); + membersByRoom.computeIfAbsent(roomId, id -> memberRepository.findAllWithUserByRoomId(id)); + evaluateCompletionAndNotify(challenge, membersByRoom.get(roomId), statsCache); + } + } + + private void evaluateCompletionAndNotify( + StudyRoomChallenge challenge, + List members, + Map statsCache) { + List userIds = members.stream().map(m -> m.getUser().getId()).toList(); + AggregationRange range = resolveAggregationRange(challenge, LocalDateTime.now()); + LocalDate streakBaseDate = min(LocalDate.now(), challenge.getEndAt().toLocalDate()); + boolean needsAttendance = isAttendanceMetric(challenge.getMetric()); + + StatsKey key = new StatsKey(userIds, streakBaseDate, challenge.getStartAt().toLocalDate(), range.start(), range.end()); + CachedStats cached = statsCache.computeIfAbsent(key, k -> { + Map streaks = statsService.currentStreaks(k.userIds(), k.streakBase(), k.challengeStart()); + Map stats = statsService.getStats(k.userIds(), k.rangeStart(), k.rangeEnd(), streaks); + Map attendance = statsService.attendanceDayCounts(k.userIds(), k.rangeStart(), k.rangeEnd()); + return new CachedStats(streaks, stats, attendance); + }); + + final CachedStats finalCached = cached; + List memberProgress = challenge.getType() == StudyRoomChallengeType.GROUP + ? List.of() + : members.stream() + .map(member -> { + int current = metricValue(challenge.getMetric(), + finalCached.stats().get(member.getUser().getId()), + needsAttendance ? finalCached.attendanceDays().getOrDefault(member.getUser().getId(), 0) : 0); + return new ChallengeMemberProgressResponse(member.getUser().getId(), member.getUser().getName(), + member.getUser().getProfileImageUrl(), current, current >= challenge.getTargetValue()); + }) + .toList(); + Integer groupCurrent = challenge.getType() == StudyRoomChallengeType.GROUP + ? userIds.stream() + .mapToInt(uid -> metricValue(challenge.getMetric(), finalCached.stats().get(uid), + needsAttendance ? finalCached.attendanceDays().getOrDefault(uid, 0) : 0)) + .sum() + : null; + StudyRoomChallengeStatus status = effectiveStatus(challenge, memberProgress, groupCurrent); + + if (challenge.getStatus() != status) { + if (status == StudyRoomChallengeStatus.COMPLETED) { + int updated = challengeRepository.tryTransitionFromInProgress(challenge.getId(), status, LocalDateTime.now()); + if (updated > 0) { + challenge.updateStatus(status); + final StudyRoomChallenge committedChallenge = challenge; + final List committedMembers = members; + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + notifyChallengeCompleted(committedChallenge, committedMembers); + } + }); + } + } else { + challenge.updateStatus(status); + } + } + } + + private record StatsKey(List userIds, LocalDate streakBase, LocalDate challengeStart, + LocalDateTime rangeStart, LocalDateTime rangeEnd) {} + + private record CachedStats(Map streaks, Map stats, + Map attendanceDays) {} + + private void notifyChallengeCompleted(StudyRoomChallenge challenge, List members) { + NotificationRequestDto dto = new NotificationRequestDto(null, + "챌린지 달성! 🎉", + "'" + challenge.getTitle() + "' 챌린지를 달성했어요!", + Map.of("type", "CHALLENGE_COMPLETED", "roomId", String.valueOf(challenge.getRoom().getId()))); + members.forEach(member -> { + try { + fcmService.sendNotificationToAllUserDevice(member.getUser().getId(), dto); + } catch (Exception e) { + log.warn("챌린지 달성 알림 발송 실패 - userId: {}", member.getUser().getId(), e); + } + }); + } + + private LocalDate min(LocalDate left, LocalDate right) { + return left.isBefore(right) ? left : right; + } + + private > T parseEnum(String value, Class enumClass) { + try { + return Enum.valueOf(enumClass, value.toUpperCase(Locale.ROOT)); + } catch (Exception e) { + throw new ApplicationException(StudyRoomErrorCase.INVALID_STUDY_ROOM_REQUEST); + } + } + + private String toApiValue(String enumName) { + return enumName.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedService.java new file mode 100644 index 00000000..51921216 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedService.java @@ -0,0 +1,165 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.common.emoji.CustomEmojiValidator; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.common.response.CursorPageResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.entity.*; +import com.aisip.OnO.backend.studyroom.event.StudyRoomActivityEvent; +import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; +import com.aisip.OnO.backend.studyroom.repository.*; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.*; +import java.util.stream.Collectors; + +import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudyRoomFeedService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + + private final StudyRoomAccessService accessService; + private final StudyRoomMemberRepository memberRepository; + private final StudyRoomFeedRepository feedRepository; + private final StudyRoomFeedReactionRepository reactionRepository; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + private final StudyRoomReactionService reactionService; + private final CustomEmojiValidator customEmojiValidator; + private final StudyRoomChallengeService challengeService; + + @Transactional(readOnly = true) + public CursorPageResponse getFeed(Long roomId, Long userId, Long cursor, int size) { + accessService.validateMember(roomId, userId); + int safeSize = Math.min(size, 50); + List feeds = feedRepository.findWithUserByRoomIdAndCursor(roomId, cursor, PageRequest.of(0, safeSize + 1)); + boolean hasNext = feeds.size() > safeSize; + List contentFeeds = hasNext ? feeds.subList(0, safeSize) : feeds; + Map> reactions = reactionRepository.findAllByFeedIds( + contentFeeds.stream().map(StudyRoomFeed::getId).toList()) + .stream() + .collect(Collectors.groupingBy(reaction -> reaction.getFeed().getId())); + List content = contentFeeds.stream() + .map(feed -> toFeedItem(feed, reactions.getOrDefault(feed.getId(), List.of()), userId)) + .toList(); + Long nextCursor = hasNext && !contentFeeds.isEmpty() ? contentFeeds.get(contentFeeds.size() - 1).getId() : null; + return new CursorPageResponse<>(content, nextCursor, hasNext, safeSize); + } + + @Transactional + public FeedReactionToggleResponse toggleReaction(Long roomId, Long feedId, Long userId, ReactionToggleRequest request) { + accessService.validateMember(roomId, userId); + customEmojiValidator.validate(request.emoji()); + StudyRoomFeed feed = feedRepository.findByIdAndRoomId(feedId, roomId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + reactionRepository.findByFeedIdAndUserIdAndEmoji(feedId, userId, request.emoji()) + .ifPresentOrElse(reactionRepository::delete, + () -> reactionRepository.save(StudyRoomFeedReaction.create(feed, user, request.emoji()))); + List reactions = reactionService.summarizeFeedReactions(reactionRepository.findAllByFeedId(feedId), userId); + return new FeedReactionToggleResponse(feedId, reactions); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handleActivity(StudyRoomActivityEvent event) { + try { + createFeedsForUserRooms(event.userId(), event.eventType(), event.metadata()); + } catch (Exception e) { + log.error("Failed to create study room feed. userId: {}, eventType: {}", event.userId(), event.eventType(), e); + } + try { + challengeService.checkAndNotifyChallengeCompletionForUser(event.userId()); + } catch (Exception e) { + log.error("Failed to check challenge completion. userId: {}, eventType: {}", event.userId(), event.eventType(), e); + } + } + + @Transactional + public void createFeedsForUserRooms(Long userId, StudyRoomFeedEventType eventType, Map metadata) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + List memberships = memberRepository.findAllWithRoomByUserId(userId); + for (StudyRoomMember membership : memberships) { + createOrAccumulate(membership.getRoom(), user, eventType, metadata == null ? Map.of() : metadata); + } + } + + private void createOrAccumulate(StudyRoom room, User user, StudyRoomFeedEventType eventType, Map metadata) { + if (eventType == StudyRoomFeedEventType.PROBLEM_REGISTERED) { + LocalDate today = LocalDate.now(KST); + LocalDateTime start = today.atStartOfDay(); + LocalDateTime end = today.atTime(LocalTime.MAX); + Optional existing = feedRepository + .findTopByRoomIdAndUserIdAndEventTypeAndCreatedAtBetweenOrderByCreatedAtDesc( + room.getId(), user.getId(), eventType, start, end); + if (existing.isPresent()) { + Map current = readMetadata(existing.get().getMetadataJson()); + int oldCount = ((Number) current.getOrDefault("count", 0)).intValue(); + int newCount = ((Number) metadata.getOrDefault("count", 1)).intValue(); + current.put("count", oldCount + newCount); + existing.get().updateMetadataJson(writeMetadata(current)); + return; + } + } + feedRepository.save(StudyRoomFeed.create(room, user, eventType, writeMetadata(metadata))); + } + + private FeedItemResponse toFeedItem(StudyRoomFeed feed, List reactions, Long userId) { + return new FeedItemResponse( + feed.getId(), + feed.getUser().getId(), + feed.getUser().getName(), + feed.getUser().getProfileImageUrl(), + toApiValue(feed.getEventType().name()), + readMetadata(feed.getMetadataJson()), + feed.getCreatedAt(), + reactionService.summarizeFeedReactions(reactions, userId) + ); + } + + private Map readMetadata(String metadataJson) { + if (metadataJson == null || metadataJson.isBlank()) { + return new LinkedHashMap<>(); + } + try { + return objectMapper.readValue(metadataJson, MAP_TYPE); + } catch (Exception e) { + return new LinkedHashMap<>(); + } + } + + private String writeMetadata(Map metadata) { + try { + return objectMapper.writeValueAsString(metadata == null ? Map.of() : metadata); + } catch (Exception e) { + throw new ApplicationException(StudyRoomErrorCase.INVALID_STUDY_ROOM_REQUEST); + } + } + + static String toApiValue(String enumName) { + return enumName.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomInviteCodeIssuer.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomInviteCodeIssuer.java new file mode 100644 index 00000000..1c4e53bb --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomInviteCodeIssuer.java @@ -0,0 +1,50 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.InviteCodeResponse; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomInviteCode; +import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomInviteCodeRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class StudyRoomInviteCodeIssuer { + + private static final int INVITE_CODE_BOUND = 1_000_000; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final StudyRoomRepository roomRepository; + private final StudyRoomInviteCodeRepository inviteCodeRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public InviteCodeResponse createOrReuse(Long roomId, LocalDateTime now) { + Optional existing = inviteCodeRepository.findTopByRoomIdOrderByExpiredAtDesc(roomId) + .filter(code -> !code.isExpired(now)); + if (existing.isPresent()) { + StudyRoomInviteCode code = existing.get(); + return new InviteCodeResponse(code.getCode(), code.getExpiredAt()); + } + + inviteCodeRepository.deleteExpiredBefore(now.minusDays(7)); + StudyRoom room = roomRepository.findById(roomId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_NOT_FOUND)); + String code; + do { + code = "%06d".formatted(RANDOM.nextInt(INVITE_CODE_BOUND)); + } while (inviteCodeRepository.existsByCode(code)); + + StudyRoomInviteCode inviteCode = inviteCodeRepository.saveAndFlush( + StudyRoomInviteCode.create(room, code, now.plusHours(24))); + return new InviteCodeResponse(inviteCode.getCode(), inviteCode.getExpiredAt()); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomInviteService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomInviteService.java new file mode 100644 index 00000000..2d2136f6 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomInviteService.java @@ -0,0 +1,82 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomInviteCode; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMemberRole; +import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomInviteCodeRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import com.aisip.OnO.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class StudyRoomInviteService { + + private static final int INVITE_CODE_RETRY_COUNT = 5; + + private final StudyRoomAccessService accessService; + private final StudyRoomInviteCodeRepository inviteCodeRepository; + private final StudyRoomMemberRepository memberRepository; + private final UserRepository userRepository; + private final StudyRoomService studyRoomService; + private final StudyRoomInviteCodeIssuer inviteCodeIssuer; + + public InviteCodeResponse issueInviteCode(Long roomId, Long userId) { + accessService.validateMember(roomId, userId); + LocalDateTime now = LocalDateTime.now(); + return inviteCodeRepository.findTopByRoomIdOrderByExpiredAtDesc(roomId) + .filter(code -> !code.isExpired(now)) + .map(code -> new InviteCodeResponse(code.getCode(), code.getExpiredAt())) + .orElseGet(() -> createInviteCode(roomId, now)); + } + + @Transactional + public StudyRoomDetailResponse join(StudyRoomJoinRequest request, Long userId) { + if (request.code() == null || !request.code().matches("\\d{6}")) { + throw new ApplicationException(StudyRoomErrorCase.INVITE_CODE_INVALID); + } + StudyRoomInviteCode inviteCode = inviteCodeRepository.findByCode(request.code()) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.INVITE_CODE_INVALID)); + if (inviteCode.isExpired(LocalDateTime.now())) { + throw new ApplicationException(StudyRoomErrorCase.INVITE_CODE_EXPIRED); + } + StudyRoom room = studyRoomService.lockRoom(inviteCode.getRoom().getId()); + if (memberRepository.existsByRoomIdAndUserId(room.getId(), userId)) { + throw new ApplicationException(StudyRoomErrorCase.ALREADY_MEMBER); + } + if (memberRepository.countByRoomId(room.getId()) >= StudyRoomService.MAX_ROOM_MEMBER_COUNT) { + throw new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_FULL); + } + if (memberRepository.countByUserId(userId) >= StudyRoomService.MAX_USER_ROOM_COUNT) { + throw new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_LIMIT_EXCEEDED); + } + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + room.addMember(StudyRoomMember.create(user, StudyRoomMemberRole.MEMBER)); + return studyRoomService.buildDetail(room); + } + + private InviteCodeResponse createInviteCode(Long roomId, LocalDateTime now) { + for (int attempt = 0; attempt < INVITE_CODE_RETRY_COUNT; attempt++) { + try { + return inviteCodeIssuer.createOrReuse(roomId, now); + } catch (DataIntegrityViolationException ignored) { + if (attempt == INVITE_CODE_RETRY_COUNT - 1) { + throw new ApplicationException(StudyRoomErrorCase.INVALID_STUDY_ROOM_REQUEST); + } + } + } + throw new ApplicationException(StudyRoomErrorCase.INVALID_STUDY_ROOM_REQUEST); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomMapper.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomMapper.java new file mode 100644 index 00000000..07d6390c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomMapper.java @@ -0,0 +1,93 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomStats; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomWeeklyReportReadRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class StudyRoomMapper { + + private final StudyRoomWeeklyReportReadRepository reportReadRepository; + + public StudyRoomListResponse toListResponse(StudyRoomMember member, List roomMembers, + Map todayPracticeCountByUserId) { + StudyRoom room = member.getRoom(); + int todayPracticeMemberCount = 0; + int todayPracticeCount = 0; + List memberSummaries = new ArrayList<>(roomMembers.size()); + for (StudyRoomMember m : roomMembers) { + int count = todayPracticeCountByUserId.getOrDefault(m.getUser().getId(), 0); + boolean practicedToday = count > 0; + if (practicedToday) todayPracticeMemberCount++; + todayPracticeCount += count; + memberSummaries.add(new StudyRoomMemberSummary( + m.getUser().getId(), + m.getUser().getProfileImageUrl(), + practicedToday + )); + } + return new StudyRoomListResponse( + room.getId(), + room.getName(), + room.getHostUserId(), + roomMembers.size(), + room.getThumbnailUrl(), + reportReadRepository.existsUnreadReport(room.getId(), member.getUser().getId()), + todayPracticeMemberCount, + todayPracticeCount, + memberSummaries + ); + } + + public StudyRoomDetailResponse toDetailResponse(StudyRoom room, List members, + Map statsByUserId, + Map todayPracticeCountsByUserId) { + List memberResponses = members.stream() + .map(member -> toMemberResponse( + member, + statsByUserId.get(member.getUser().getId()), + todayPracticeCountsByUserId.getOrDefault(member.getUser().getId(), 0) + )) + .toList(); + return new StudyRoomDetailResponse( + room.getId(), + room.getName(), + room.getHostUserId(), + room.getThumbnailUrl(), + memberResponses.size(), + memberResponses + ); + } + + public StudyRoomMemberResponse toMemberResponse(StudyRoomMember member, StudyRoomStats stats, int todayPracticeCount) { + int weeklyProblemCount = stats == null ? 0 : stats.weeklyProblemCount(); + int weeklyPracticeCount = stats == null ? 0 : stats.weeklyPracticeCount(); + int currentStreak = stats == null ? 0 : stats.currentStreak(); + Integer goalProgress = member.getWeeklyGoal() == null ? null : weeklyProblemCount; + UserMissionStatus missionStatus = member.getUser().getUserMissionStatus(); + Long totalStudyLevel = missionStatus == null ? 1L : missionStatus.getTotalStudyLevel(); + return new StudyRoomMemberResponse( + member.getUser().getId(), + member.getUser().getName(), + member.getUser().getProfileImageUrl(), + totalStudyLevel, + currentStreak, + weeklyProblemCount, + weeklyPracticeCount, + member.getWeeklyGoal(), + goalProgress, + todayPracticeCount, + todayPracticeCount > 0 + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomReactionService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomReactionService.java new file mode 100644 index 00000000..cd3f1e6a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomReactionService.java @@ -0,0 +1,51 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.ReactionResponse; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedReaction; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemCommentReaction; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemReaction; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +@Component +public class StudyRoomReactionService { + + public List summarizeFeedReactions(Collection reactions, Long userId) { + return summarize(reactions, userId, StudyRoomFeedReaction::getEmoji, reaction -> reaction.getUser().getId()); + } + + public List summarizeSharedProblemReactions(Collection reactions, Long userId) { + return summarize(reactions, userId, StudyRoomSharedProblemReaction::getEmoji, reaction -> reaction.getUser().getId()); + } + + public List summarizeSharedProblemCommentReactions(Collection reactions, Long userId) { + return summarize(reactions, userId, StudyRoomSharedProblemCommentReaction::getEmoji, reaction -> reaction.getUser().getId()); + } + + private List summarize(Collection reactions, Long userId, + Function emojiGetter, + Function userIdGetter) { + Map grouped = new LinkedHashMap<>(); + for (T reaction : reactions) { + String emoji = emojiGetter.apply(reaction); + MutableReaction mutable = grouped.computeIfAbsent(emoji, key -> new MutableReaction()); + mutable.count++; + if (userId.equals(userIdGetter.apply(reaction))) { + mutable.reactedByMe = true; + } + } + return grouped.entrySet().stream() + .map(entry -> new ReactionResponse(entry.getKey(), entry.getValue().count, entry.getValue().reactedByMe)) + .toList(); + } + + private static class MutableReaction { + private long count; + private boolean reactedByMe; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomService.java new file mode 100644 index 00000000..920be37f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomService.java @@ -0,0 +1,346 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomStats; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMemberRole; +import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; +import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; +import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class StudyRoomService { + + public static final int MAX_ROOM_MEMBER_COUNT = 20; + public static final int MAX_USER_ROOM_COUNT = 10; + private static final long MAX_THUMBNAIL_SIZE_BYTES = 5 * 1024 * 1024; + + private final StudyRoomRepository roomRepository; + private final StudyRoomMemberRepository memberRepository; + private final UserRepository userRepository; + private final StudyRoomAccessService accessService; + private final StudyRoomStatsService statsService; + private final StudyRoomMapper mapper; + private final FileUploadService fileUploadService; + private final S3DeleteProducer s3DeleteProducer; + + @Transactional(readOnly = true) + public List getMyRooms(Long userId) { + List memberships = memberRepository.findAllWithRoomByUserId(userId); + List roomIds = memberships.stream().map(member -> member.getRoom().getId()).toList(); + if (roomIds.isEmpty()) { + return List.of(); + } + List allRoomMembers = memberRepository.findAllWithRoomAndUserByRoomIds(roomIds); + Map> membersByRoomId = allRoomMembers.stream() + .collect(java.util.stream.Collectors.groupingBy(m -> m.getRoom().getId())); + List allUserIds = allRoomMembers.stream().map(m -> m.getUser().getId()).distinct().toList(); + Map todayPracticeCountByUserId = statsService.getTodayPracticeCounts(allUserIds); + return memberships.stream() + .map(member -> mapper.toListResponse( + member, + membersByRoomId.getOrDefault(member.getRoom().getId(), List.of()), + todayPracticeCountByUserId + )) + .toList(); + } + + @Transactional(readOnly = true) + public StudyRoomDetailResponse getRoom(Long roomId, Long userId) { + accessService.validateMember(roomId, userId); + StudyRoom room = accessService.getRoomOrThrow(roomId); + return buildDetail(room); + } + + @Transactional + public StudyRoomDetailResponse createRoom(StudyRoomCreateRequest request, Long userId) { + return createRoom(request == null ? null : request.name(), null, userId); + } + + @Transactional + public StudyRoomDetailResponse createRoom(String name, MultipartFile thumbnailImage, Long userId) { + String validatedName = validateName(name); + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + if (memberRepository.countByUserId(userId) >= MAX_USER_ROOM_COUNT) { + throw new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_LIMIT_EXCEEDED); + } + StudyRoom room = StudyRoom.create(validatedName, userId); + room.addMember(StudyRoomMember.create(user, StudyRoomMemberRole.HOST)); + roomRepository.save(room); + if (thumbnailImage != null && !thumbnailImage.isEmpty()) { + validateThumbnail(thumbnailImage); + String thumbnailUrl = fileUploadService.uploadFileToS3(thumbnailImage); + deleteThumbnailOnRollback(thumbnailUrl, room.getId()); + room.updateThumbnailUrl(thumbnailUrl); + } + return buildDetail(room); + } + + @Transactional + public StudyRoomDetailResponse updateRoom(Long roomId, Long userId, StudyRoomUpdateRequest request) { + String name = validateName(request == null ? null : request.name()); + accessService.validateHost(roomId, userId); + StudyRoom room = lockRoom(roomId); + room.updateName(name); + return buildDetail(room); + } + + @Transactional + public StudyRoomDetailResponse updateRoom(Long roomId, Long userId, String name, MultipartFile thumbnailImage) { + if ((name == null || name.isBlank()) && (thumbnailImage == null || thumbnailImage.isEmpty())) { + throw new ApplicationException(StudyRoomErrorCase.INVALID_STUDY_ROOM_REQUEST); + } + accessService.validateHost(roomId, userId); + StudyRoom room = lockRoom(roomId); + if (name != null) { + room.updateName(validateName(name)); + } + if (thumbnailImage != null) { + validateThumbnail(thumbnailImage); + String previousThumbnailUrl = room.getThumbnailUrl(); + String thumbnailUrl = fileUploadService.uploadFileToS3(thumbnailImage); + deleteThumbnailOnRollback(thumbnailUrl, roomId); + room.updateThumbnailUrl(thumbnailUrl); + deleteThumbnailAsync(previousThumbnailUrl, roomId); + } + return buildDetail(room); + } + + @Transactional + public void deleteRoom(Long roomId, Long userId) { + accessService.validateHost(roomId, userId); + StudyRoom room = lockRoom(roomId); + deleteThumbnailAsync(room.getThumbnailUrl(), roomId); + roomRepository.delete(room); + } + + @Transactional + public StudyRoom lockRoom(Long roomId) { + return roomRepository.findByIdForUpdate(roomId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_NOT_FOUND)); + } + + @Transactional + public void leaveRoom(Long roomId, Long userId) { + StudyRoomMember member = accessService.getMemberOrThrow(roomId, userId); + if (member.getRole() != StudyRoomMemberRole.HOST) { + memberRepository.deleteByRoomIdAndUserId(roomId, userId); + return; + } + + StudyRoom room = roomRepository.findByIdForUpdate(roomId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_NOT_FOUND)); + List members = memberRepository.findAllWithUserByRoomId(roomId); + StudyRoomMember nextHost = members.stream() + .filter(candidate -> !candidate.getUser().getId().equals(userId)) + .findFirst() + .orElse(null); + if (nextHost == null) { + deleteThumbnailAsync(room.getThumbnailUrl(), roomId); + roomRepository.delete(room); + return; + } + nextHost.promoteToHost(); + room.updateHostUserId(nextHost.getUser().getId()); + memberRepository.deleteByRoomIdAndUserId(roomId, userId); + } + + @Transactional + public void kickMember(Long roomId, Long memberUserId, Long hostUserId) { + accessService.validateHost(roomId, hostUserId); + StudyRoomMember member = accessService.getMemberOrThrow(roomId, memberUserId); + if (member.getRole() == StudyRoomMemberRole.HOST) { + throw new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_HOST_ONLY); + } + memberRepository.deleteByRoomIdAndUserId(roomId, memberUserId); + } + + @Transactional + public GoalUpdateResponse updateGoal(Long roomId, Long userId, StudyRoomGoalUpdateRequest request) { + StudyRoomMember member = accessService.getMemberOrThrow(roomId, userId); + Integer weeklyGoal = request.weeklyGoal(); + if (weeklyGoal != null && weeklyGoal < 0) { + throw new ApplicationException(StudyRoomErrorCase.INVALID_STUDY_ROOM_REQUEST); + } + member.updateWeeklyGoal(weeklyGoal == null || weeklyGoal == 0 ? null : weeklyGoal); + StudyRoomStats stats = statsService.getWeeklyStats(List.of(userId)).get(userId); + return new GoalUpdateResponse(member.getWeeklyGoal(), member.getWeeklyGoal() == null ? null : stats.weeklyProblemCount()); + } + + @Transactional + public StudyRoomThumbnailUpdateResponse updateThumbnail(Long roomId, Long userId, MultipartFile thumbnail) { + accessService.validateHost(roomId, userId); + validateThumbnail(thumbnail); + StudyRoom room = accessService.getRoomOrThrow(roomId); + String previousThumbnailUrl = room.getThumbnailUrl(); + String thumbnailUrl = fileUploadService.uploadFileToS3(thumbnail); + deleteThumbnailOnRollback(thumbnailUrl, roomId); + room.updateThumbnailUrl(thumbnailUrl); + deleteThumbnailAsync(previousThumbnailUrl, roomId); + return new StudyRoomThumbnailUpdateResponse(thumbnailUrl); + } + + @Transactional + public StudyRoomThumbnailUpdateResponse updateThumbnailByUrl(Long roomId, Long userId, String thumbnailUrl) { + accessService.validateHost(roomId, userId); + fileUploadService.validateS3Url(thumbnailUrl); + StudyRoom room = accessService.getRoomOrThrow(roomId); + String previousThumbnailUrl = room.getThumbnailUrl(); + room.updateThumbnailUrl(thumbnailUrl); + deleteThumbnailAsync(previousThumbnailUrl, roomId); + return new StudyRoomThumbnailUpdateResponse(thumbnailUrl); + } + + StudyRoomDetailResponse buildDetail(StudyRoom room) { + List members = memberRepository.findAllWithUserByRoomId(room.getId()); + List userIds = members.stream().map(member -> member.getUser().getId()).toList(); + Map stats = statsService.getWeeklyStats(userIds); + Map todayPracticeCounts = statsService.getTodayPracticeCounts(userIds); + return mapper.toDetailResponse(room, members, stats, todayPracticeCounts); + } + + private String validateName(String name) { + if (name == null || name.isBlank() || name.length() > 20) { + throw new ApplicationException(StudyRoomErrorCase.INVALID_STUDY_ROOM_REQUEST); + } + return name.trim(); + } + + private void validateThumbnail(MultipartFile thumbnail) { + if (thumbnail == null || thumbnail.isEmpty()) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + if (thumbnail.getSize() > MAX_THUMBNAIL_SIZE_BYTES) { + throw new ApplicationException(FileUploadErrorCase.FILE_SIZE_EXCEEDED); + } + + String extension = getLowercaseExtension(thumbnail.getOriginalFilename()); + String contentType = normalizeContentType(thumbnail.getContentType(), extension); + if (!isAllowedImageType(contentType, extension) || !hasAllowedImageSignature(thumbnail, contentType)) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + } + + private String getLowercaseExtension(String originalFilename) { + if (originalFilename == null) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + int dotIndex = originalFilename.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex == originalFilename.length() - 1) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + return originalFilename.substring(dotIndex + 1).toLowerCase(Locale.ROOT); + } + + private String normalizeContentType(String contentType, String extension) { + if (contentType == null || "application/octet-stream".equals(contentType)) { + return switch (extension) { + case "jpg", "jpeg" -> "image/jpeg"; + case "png" -> "image/png"; + case "webp" -> "image/webp"; + default -> contentType != null ? contentType : ""; + }; + } + return contentType; + } + + private boolean isAllowedImageType(String contentType, String extension) { + if (contentType == null) { + return false; + } + return switch (contentType) { + case "image/jpeg", "image/jpg" -> extension.equals("jpg") || extension.equals("jpeg"); + case "image/png" -> extension.equals("png"); + case "image/webp" -> extension.equals("webp"); + default -> false; + }; + } + + private boolean hasAllowedImageSignature(MultipartFile thumbnail, String contentType) { + byte[] header = new byte[12]; + int read; + try { + read = thumbnail.getInputStream().read(header); + } catch (IOException e) { + throw new ApplicationException(FileUploadErrorCase.FILE_UPLOAD_FAILED); + } + + return switch (contentType) { + case "image/jpeg", "image/jpg" -> read >= 3 + && (header[0] & 0xFF) == 0xFF + && (header[1] & 0xFF) == 0xD8 + && (header[2] & 0xFF) == 0xFF; + case "image/png" -> read >= 8 + && (header[0] & 0xFF) == 0x89 + && header[1] == 0x50 + && header[2] == 0x4E + && header[3] == 0x47 + && header[4] == 0x0D + && header[5] == 0x0A + && header[6] == 0x1A + && header[7] == 0x0A; + case "image/webp" -> read >= 12 + && header[0] == 0x52 + && header[1] == 0x49 + && header[2] == 0x46 + && header[3] == 0x46 + && header[8] == 0x57 + && header[9] == 0x45 + && header[10] == 0x42 + && header[11] == 0x50; + default -> false; + }; + } + + private void deleteThumbnailAsync(String thumbnailUrl, Long roomId) { + if (thumbnailUrl == null || thumbnailUrl.isBlank()) { + return; + } + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + s3DeleteProducer.sendDeleteMessage(thumbnailUrl, roomId); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + s3DeleteProducer.sendDeleteMessage(thumbnailUrl, roomId); + } + }); + } + + private void deleteThumbnailOnRollback(String thumbnailUrl, Long roomId) { + if (thumbnailUrl == null || thumbnailUrl.isBlank() + || !TransactionSynchronizationManager.isSynchronizationActive()) { + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == STATUS_ROLLED_BACK) { + s3DeleteProducer.sendDeleteMessage(thumbnailUrl, roomId); + } + } + }); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemCommentService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemCommentService.java new file mode 100644 index 00000000..6340051d --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemCommentService.java @@ -0,0 +1,191 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.common.emoji.CustomEmojiValidator; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.common.response.CursorPageResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMemberRole; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblem; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemComment; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemCommentReaction; +import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemRepository; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudyRoomSharedProblemCommentService { + + private static final int MAX_COMMENT_LENGTH = 300; + + private final StudyRoomAccessService accessService; + private final StudyRoomSharedProblemRepository sharedProblemRepository; + private final StudyRoomSharedProblemCommentRepository commentRepository; + private final StudyRoomSharedProblemCommentReactionRepository commentReactionRepository; + private final UserRepository userRepository; + private final StudyRoomReactionService reactionService; + private final CustomEmojiValidator customEmojiValidator; + private final FcmService fcmService; + + @Transactional(readOnly = true) + public CursorPageResponse getComments(Long roomId, Long sharedProblemId, + Long userId, Long cursor, int size) { + accessService.validateMember(roomId, userId); + getSharedProblemOrThrow(roomId, sharedProblemId); + int safeSize = Math.min(Math.max(size, 1), 50); + List comments = commentRepository.findBySharedProblemIdAndCursor( + sharedProblemId, cursor, PageRequest.of(0, safeSize + 1)); + boolean hasNext = comments.size() > safeSize; + List contentComments = hasNext ? comments.subList(0, safeSize) : comments; + List commentIds = contentComments.stream() + .map(StudyRoomSharedProblemComment::getId) + .toList(); + Map> reactions = commentIds.isEmpty() + ? Map.of() + : commentReactionRepository.findAllByCommentIds(commentIds).stream() + .collect(Collectors.groupingBy(reaction -> reaction.getComment().getId())); + List content = contentComments.stream() + .map(comment -> toResponse(comment, reactions.getOrDefault(comment.getId(), List.of()), userId)) + .toList(); + Long nextCursor = hasNext && !contentComments.isEmpty() + ? contentComments.get(contentComments.size() - 1).getId() + : null; + return new CursorPageResponse<>(content, nextCursor, hasNext, safeSize); + } + + @Transactional + public SharedProblemCommentResponse createComment(Long roomId, Long sharedProblemId, Long userId, + SharedProblemCommentRequest request) { + accessService.validateMember(roomId, userId); + StudyRoomSharedProblem sharedProblem = getSharedProblemOrThrow(roomId, sharedProblemId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + StudyRoomSharedProblemComment comment = commentRepository.save( + StudyRoomSharedProblemComment.create(sharedProblem, user, validateContent(request))); + Long sharerId = sharedProblem.getSharedByUser().getId(); + if (!sharerId.equals(userId)) { + String preview = comment.getContent().length() > 50 + ? comment.getContent().substring(0, 50) + "…" + : comment.getContent(); + try { + fcmService.sendNotificationToAllUserDevice(sharerId, new NotificationRequestDto(null, + "공유 문제에 댓글이 달렸어요", + user.getName() + ": " + preview, + Map.of("type", "SHARED_PROBLEM_COMMENT", "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblemId)))); + } catch (Exception e) { + log.warn("댓글 알림 발송 실패 - userId: {}", sharerId, e); + } + } + return toResponse(comment, List.of(), userId, false); + } + + @Transactional + public SharedProblemCommentResponse updateComment(Long roomId, Long sharedProblemId, Long commentId, + Long userId, SharedProblemCommentRequest request) { + accessService.validateMember(roomId, userId); + StudyRoomSharedProblemComment comment = getCommentOrThrow(roomId, sharedProblemId, commentId); + if (!comment.getAuthor().getId().equals(userId)) { + throw new ApplicationException(StudyRoomErrorCase.SHARED_PROBLEM_COMMENT_FORBIDDEN); + } + comment.updateContent(validateContent(request)); + return toResponse(comment, commentReactionRepository.findAllByCommentId(commentId), userId, true); + } + + @Transactional + public SharedProblemCommentReactionToggleResponse toggleReaction(Long roomId, Long sharedProblemId, Long commentId, + Long userId, ReactionToggleRequest request) { + accessService.validateMember(roomId, userId); + customEmojiValidator.validate(request.emoji()); + StudyRoomSharedProblemComment comment = getCommentOrThrow(roomId, sharedProblemId, commentId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + commentReactionRepository.findByCommentIdAndUserIdAndEmoji(commentId, userId, request.emoji()) + .ifPresentOrElse(commentReactionRepository::delete, + () -> commentReactionRepository.save(StudyRoomSharedProblemCommentReaction.create(comment, user, request.emoji()))); + return new SharedProblemCommentReactionToggleResponse(commentId, + reactionService.summarizeSharedProblemCommentReactions(commentReactionRepository.findAllByCommentId(commentId), userId)); + } + + @Transactional + public void deleteComment(Long roomId, Long sharedProblemId, Long commentId, Long userId) { + StudyRoomMember member = accessService.getMemberOrThrow(roomId, userId); + StudyRoomSharedProblemComment comment = getCommentOrThrow(roomId, sharedProblemId, commentId); + boolean mine = comment.getAuthor().getId().equals(userId); + boolean host = member.getRole() == StudyRoomMemberRole.HOST; + if (!mine && !host) { + throw new ApplicationException(StudyRoomErrorCase.SHARED_PROBLEM_COMMENT_FORBIDDEN); + } + commentReactionRepository.deleteByCommentId(commentId); + commentRepository.delete(comment); + } + + private StudyRoomSharedProblem getSharedProblemOrThrow(Long roomId, Long sharedProblemId) { + return sharedProblemRepository.findByIdAndRoomId(sharedProblemId, roomId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.SHARED_PROBLEM_NOT_FOUND)); + } + + private StudyRoomSharedProblemComment getCommentOrThrow(Long roomId, Long sharedProblemId, Long commentId) { + return commentRepository.findByIdAndSharedProblemIdAndRoomId(commentId, sharedProblemId, roomId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.SHARED_PROBLEM_COMMENT_NOT_FOUND)); + } + + private String validateContent(SharedProblemCommentRequest request) { + String content = request == null ? null : request.content(); + if (content == null || content.isBlank() || content.length() > MAX_COMMENT_LENGTH) { + throw new ApplicationException(StudyRoomErrorCase.INVALID_SHARED_PROBLEM_COMMENT); + } + return content.trim(); + } + + private SharedProblemCommentResponse toResponse(StudyRoomSharedProblemComment comment, Long userId) { + return toResponse(comment, List.of(), userId, false); + } + + private SharedProblemCommentResponse toResponse(StudyRoomSharedProblemComment comment, + List reactions, + Long userId) { + return toResponse(comment, reactions, userId, false); + } + + private SharedProblemCommentResponse toResponse(StudyRoomSharedProblemComment comment, + List reactions, + Long userId, + boolean forceEdited) { + LocalDateTime createdAt = comment.getCreatedAt(); + LocalDateTime updatedAt = comment.getUpdatedAt(); + Long authorId = comment.getAuthor().getId(); + boolean mine = authorId.equals(userId); + boolean canDelete = mine || comment.getSharedProblem().getRoom().getHostUserId().equals(userId); + return new SharedProblemCommentResponse( + comment.getId(), + comment.getContent(), + authorId, + comment.getAuthor().getName(), + comment.getAuthor().getProfileImageUrl(), + createdAt, + updatedAt, + forceEdited || createdAt != null && updatedAt != null && updatedAt.isAfter(createdAt), + mine, + canDelete, + reactionService.summarizeSharedProblemCommentReactions(reactions, userId) + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemService.java new file mode 100644 index 00000000..126b40b5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemService.java @@ -0,0 +1,205 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.common.emoji.CustomEmojiValidator; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.common.response.CursorPageResponse; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.entity.ProblemImageData; +import com.aisip.OnO.backend.problem.entity.ProblemImageType; +import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.entity.*; +import com.aisip.OnO.backend.studyroom.event.StudyRoomActivityEvent; +import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; +import com.aisip.OnO.backend.studyroom.repository.*; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudyRoomSharedProblemService { + + private final StudyRoomAccessService accessService; + private final StudyRoomSharedProblemRepository sharedProblemRepository; + private final StudyRoomSharedProblemReactionRepository reactionRepository; + private final StudyRoomSharedProblemCommentRepository commentRepository; + private final StudyRoomSharedProblemCommentReactionRepository commentReactionRepository; + private final StudyRoomMemberRepository memberRepository; + private final UserRepository userRepository; + private final ProblemService problemService; + private final StudyRoomReactionService reactionService; + private final ApplicationEventPublisher eventPublisher; + private final CustomEmojiValidator customEmojiValidator; + private final FcmService fcmService; + + @Transactional(readOnly = true) + public CursorPageResponse getSharedProblems(Long roomId, Long userId, Long cursor, int size) { + accessService.validateMember(roomId, userId); + int safeSize = Math.min(size, 50); + List sharedProblems = sharedProblemRepository.findByRoomIdAndCursor(roomId, cursor, PageRequest.of(0, safeSize + 1)); + boolean hasNext = sharedProblems.size() > safeSize; + List contentSharedProblems = hasNext ? sharedProblems.subList(0, safeSize) : sharedProblems; + List sharedProblemIds = contentSharedProblems.stream() + .map(StudyRoomSharedProblem::getId) + .toList(); + Map> reactions = sharedProblemIds.isEmpty() + ? Map.of() + : reactionRepository.findAllBySharedProblemIds(sharedProblemIds).stream() + .collect(Collectors.groupingBy(reaction -> reaction.getSharedProblem().getId())); + Map commentCounts = sharedProblemIds.isEmpty() + ? Map.of() + : commentRepository.countBySharedProblemIds(sharedProblemIds).stream() + .collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); + List content = contentSharedProblems.stream() + .map(sharedProblem -> toResponse( + sharedProblem, + reactions.getOrDefault(sharedProblem.getId(), List.of()), + commentCounts.getOrDefault(sharedProblem.getId(), 0L), + userId)) + .toList(); + Long nextCursor = hasNext && !contentSharedProblems.isEmpty() + ? contentSharedProblems.get(contentSharedProblems.size() - 1).getId() + : null; + return new CursorPageResponse<>(content, nextCursor, hasNext, safeSize); + } + + @Transactional + public SharedProblemResponse shareProblem(Long roomId, Long userId, SharedProblemCreateRequest request) { + accessService.validateMember(roomId, userId); + if (request.problemId() == null || request.comment() != null && request.comment().length() > 100) { + throw new ApplicationException(StudyRoomErrorCase.INVALID_STUDY_ROOM_REQUEST); + } + if (sharedProblemRepository.existsByRoomIdAndProblemId(roomId, request.problemId())) { + throw new ApplicationException(StudyRoomErrorCase.ALREADY_SHARED_PROBLEM); + } + Problem problem = problemService.findProblemEntityWithImageData(request.problemId(), userId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + StudyRoomSharedProblem sharedProblem = sharedProblemRepository.save( + StudyRoomSharedProblem.create(accessService.getRoomOrThrow(roomId), user, problem, request.comment())); + eventPublisher.publishEvent(new StudyRoomActivityEvent(userId, StudyRoomFeedEventType.PROBLEM_SHARED, + Map.of("reference", problem.getReference() == null ? "" : problem.getReference(), + "sharedProblemId", sharedProblem.getId()))); + notifyRoomMembers(roomId, userId, + user.getName() + "님이 문제를 공유했어요", + referenceOrFallback(problem.getReference()), + Map.of("type", "SHARED_PROBLEM", "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblem.getId()))); + return toResponse(sharedProblem, List.of(), 0L, userId); + } + + @Transactional + public SharedProblemReactionToggleResponse toggleReaction(Long roomId, Long sharedProblemId, Long userId, ReactionToggleRequest request) { + accessService.validateMember(roomId, userId); + customEmojiValidator.validate(request.emoji()); + StudyRoomSharedProblem sharedProblem = sharedProblemRepository.findByIdAndRoomId(sharedProblemId, roomId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.SHARED_PROBLEM_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + boolean added; + var existing = reactionRepository.findBySharedProblemIdAndUserIdAndEmoji(sharedProblemId, userId, request.emoji()); + if (existing.isPresent()) { + reactionRepository.delete(existing.get()); + added = false; + } else { + reactionRepository.save(StudyRoomSharedProblemReaction.create(sharedProblem, user, request.emoji())); + added = true; + } + Long sharerId = sharedProblem.getSharedByUser().getId(); + if (added && !sharerId.equals(userId)) { + notifyUser(sharerId, + "공유 문제에 반응이 달렸어요", + user.getName() + "님이 반응을 추가했어요.", + Map.of("type", "SHARED_PROBLEM_REACTION", "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblemId))); + } + return new SharedProblemReactionToggleResponse(sharedProblemId, + reactionService.summarizeSharedProblemReactions(reactionRepository.findAllBySharedProblemId(sharedProblemId), userId)); + } + + @Transactional + public void deleteSharedProblem(Long roomId, Long sharedProblemId, Long userId) { + accessService.validateMember(roomId, userId); + StudyRoomSharedProblem sharedProblem = sharedProblemRepository.findByIdAndRoomId(sharedProblemId, roomId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.SHARED_PROBLEM_NOT_FOUND)); + if (!sharedProblem.getSharedByUser().getId().equals(userId)) { + throw new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_FORBIDDEN); + } + commentReactionRepository.deleteBySharedProblemId(sharedProblemId); + commentRepository.deleteBySharedProblemId(sharedProblemId); + reactionRepository.deleteBySharedProblemId(sharedProblemId); + sharedProblemRepository.delete(sharedProblem); + } + + private SharedProblemResponse toResponse(StudyRoomSharedProblem sharedProblem, + List reactions, + long commentCount, + Long userId) { + Problem problem = sharedProblem.getProblem(); + return new SharedProblemResponse( + sharedProblem.getId(), + sharedProblem.getSharedByUser().getId(), + sharedProblem.getSharedByUser().getName(), + sharedProblem.getSharedByUser().getProfileImageUrl(), + problem.getId(), + problemImageUrl(problem), + problemImageUrls(problem), + referenceOrFallback(problem.getReference()), + sharedProblem.getComment(), + commentCount, + sharedProblem.getCreatedAt(), + reactionService.summarizeSharedProblemReactions(reactions, userId) + ); + } + + private String referenceOrFallback(String reference) { + return reference == null || reference.isBlank() ? "공유 문제" : reference; + } + + private List problemImageUrls(Problem problem) { + return problem.getProblemImageDataList().stream() + .filter(image -> image.getProblemImageType() == ProblemImageType.PROBLEM_IMAGE) + .map(ProblemImageData::getImageUrl) + .toList(); + } + + private String problemImageUrl(Problem problem) { + return problemImageUrls(problem).stream() + .findFirst() + .orElse(null); + } + + private void notifyRoomMembers(Long roomId, Long excludeUserId, String title, String body, Map data) { + NotificationRequestDto dto = new NotificationRequestDto(null, title, body, data); + memberRepository.findAllWithUserByRoomId(roomId).forEach(member -> { + Long memberId = member.getUser().getId(); + if (!memberId.equals(excludeUserId)) { + notifyUser(memberId, dto); + } + }); + } + + private void notifyUser(Long userId, String title, String body, Map data) { + notifyUser(userId, new NotificationRequestDto(null, title, body, data)); + } + + private void notifyUser(Long userId, NotificationRequestDto dto) { + try { + fcmService.sendNotificationToAllUserDevice(userId, dto); + } catch (Exception e) { + log.warn("공유 문제 알림 발송 실패 - userId: {}", userId, e); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomStatsService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomStatsService.java new file mode 100644 index 00000000..79080841 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomStatsService.java @@ -0,0 +1,232 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.studyroom.dto.StudyRoomStats; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomTodayPracticeSummary; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.temporal.TemporalAdjusters; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.Collection; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StudyRoomStatsService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final EntityManager entityManager; + + public Map getWeeklyStats(Collection userIds) { + LocalDate today = LocalDate.now(KST); + LocalDate startDate = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDateTime start = startDate.atStartOfDay(); + LocalDateTime end = today.atTime(LocalTime.MAX); + return getStats(userIds, start, end, today); + } + + public Map getTodayPracticeCounts(Collection userIds) { + LocalDate today = LocalDate.now(KST); + return countPractices(userIds, today.atStartOfDay(), today.atTime(LocalTime.MAX)); + } + + public Map getTodayPracticeSummariesByRoomIds(Collection roomIds) { + List ids = roomIds.stream().distinct().toList(); + if (ids.isEmpty()) { + return Map.of(); + } + LocalDate today = LocalDate.now(KST); + List rows = entityManager.createQuery(""" + select m.room.id, count(distinct ps.userId), count(ps.id) + from StudyRoomMember m + join ProblemSolve ps on ps.userId = m.user.id + where m.room.id in :roomIds and ps.practicedAt between :start and :end + group by m.room.id + """, Object[].class) + .setParameter("roomIds", ids) + .setParameter("start", today.atStartOfDay()) + .setParameter("end", today.atTime(LocalTime.MAX)) + .getResultList(); + Map result = new HashMap<>(); + for (Object[] row : rows) { + result.put((Long) row[0], new StudyRoomTodayPracticeSummary( + Math.toIntExact((Long) row[1]), + Math.toIntExact((Long) row[2]) + )); + } + return result; + } + + public Map getStats(Collection userIds, LocalDateTime start, LocalDateTime end, LocalDate today) { + List ids = userIds.stream().distinct().toList(); + Map streaks = currentStreaks(ids, today); + return getStats(ids, start, end, streaks); + } + + public Map getStats(Collection userIds, LocalDateTime start, LocalDateTime end, + Map streaks) { + List ids = userIds.stream().distinct().toList(); + Map problems = countProblems(userIds, start, end); + Map practices = countPractices(userIds, start, end); + Map result = new HashMap<>(); + for (Long userId : ids) { + result.put(userId, new StudyRoomStats( + problems.getOrDefault(userId, 0), + practices.getOrDefault(userId, 0), + streaks.getOrDefault(userId, 0) + )); + } + return result; + } + + public Map attendanceDayCounts(Collection userIds, LocalDateTime start, LocalDateTime end) { + List ids = userIds.stream().distinct().toList(); + if (ids.isEmpty()) { + return Map.of(); + } + LocalDate today = end.toLocalDate(); + Map> studyDates = new HashMap<>(); + addStudyDates(studyDates, findProblemStudyDates(ids, today, start)); + addStudyDates(studyDates, findPracticeStudyDates(ids, today, start)); + + Map result = new HashMap<>(); + for (Long userId : ids) { + result.put(userId, studyDates.getOrDefault(userId, new TreeSet<>()).size()); + } + return result; + } + + public Map currentStreaks(Collection userIds, LocalDate today) { + return currentStreaks(userIds, today, null); + } + + public Map currentStreaks(Collection userIds, LocalDate today, LocalDate startDate) { + List ids = userIds.stream().distinct().toList(); + if (ids.isEmpty()) { + return Map.of(); + } + LocalDateTime startAt = (startDate == null ? LocalDate.of(1970, 1, 1) : startDate).atStartOfDay(); + Map> studyDates = new HashMap<>(); + addStudyDates(studyDates, findProblemStudyDates(ids, today, startAt)); + addStudyDates(studyDates, findPracticeStudyDates(ids, today, startAt)); + + Map result = new HashMap<>(); + for (Long userId : ids) { + result.put(userId, currentStreak(studyDates.getOrDefault(userId, new TreeSet<>()), today)); + } + return result; + } + + private Map countProblems(Collection userIds, LocalDateTime start, LocalDateTime end) { + if (userIds.isEmpty()) { + return Map.of(); + } + List rows = entityManager.createQuery(""" + select p.userId, count(p.id) + from Problem p + where p.userId in :userIds and p.createdAt between :start and :end + group by p.userId + """, Object[].class) + .setParameter("userIds", userIds) + .setParameter("start", start) + .setParameter("end", end) + .getResultList(); + return toCountMap(rows); + } + + private Map countPractices(Collection userIds, LocalDateTime start, LocalDateTime end) { + if (userIds.isEmpty()) { + return Map.of(); + } + List rows = entityManager.createQuery(""" + select ps.userId, count(ps.id) + from ProblemSolve ps + where ps.userId in :userIds and ps.practicedAt between :start and :end + group by ps.userId + """, Object[].class) + .setParameter("userIds", userIds) + .setParameter("start", start) + .setParameter("end", end) + .getResultList(); + return toCountMap(rows); + } + + private Map toCountMap(List rows) { + Map result = new HashMap<>(); + for (Object[] row : rows) { + result.put((Long) row[0], Math.toIntExact((Long) row[1])); + } + return result; + } + + private List findProblemStudyDates(List userIds, LocalDate today, LocalDateTime startAt) { + return entityManager.createNativeQuery(""" + select p.user_id, date(p.created_at) + from problem p + where p.user_id in (:userIds) + and p.created_at >= :startAt + and p.created_at < :endExclusive + and p.deleted_at is null + group by p.user_id, date(p.created_at) + """) + .setParameter("userIds", userIds) + .setParameter("startAt", startAt) + .setParameter("endExclusive", today.plusDays(1).atStartOfDay()) + .getResultList(); + } + + private List findPracticeStudyDates(List userIds, LocalDate today, LocalDateTime startAt) { + return entityManager.createNativeQuery(""" + select ps.user_id, date(ps.practiced_at) + from problem_solve ps + where ps.user_id in (:userIds) + and ps.practiced_at >= :startAt + and ps.practiced_at < :endExclusive + and ps.deleted_at is null + group by ps.user_id, date(ps.practiced_at) + """) + .setParameter("userIds", userIds) + .setParameter("startAt", startAt) + .setParameter("endExclusive", today.plusDays(1).atStartOfDay()) + .getResultList(); + } + + private void addStudyDates(Map> studyDates, List rows) { + for (Object[] row : rows) { + Long userId = ((Number) row[0]).longValue(); + LocalDate date = toLocalDate(row[1]); + studyDates.computeIfAbsent(userId, key -> new TreeSet<>()).add(date); + } + } + + private LocalDate toLocalDate(Object value) { + if (value instanceof java.sql.Date date) { + return date.toLocalDate(); + } + if (value instanceof LocalDate date) { + return date; + } + return LocalDate.parse(value.toString()); + } + + private int currentStreak(TreeSet dates, LocalDate today) { + LocalDate cursor = dates.contains(today) ? today : today.minusDays(1); + int streak = 0; + while (dates.contains(cursor)) { + streak++; + cursor = cursor.minusDays(1); + } + return streak; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomWeeklyReportService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomWeeklyReportService.java new file mode 100644 index 00000000..a8a915cc --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomWeeklyReportService.java @@ -0,0 +1,221 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomStats; +import com.aisip.OnO.backend.studyroom.entity.*; +import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; +import com.aisip.OnO.backend.studyroom.repository.*; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import com.aisip.OnO.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.temporal.TemporalAdjusters; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class StudyRoomWeeklyReportService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final String DEFAULT_CHEER_MESSAGE = "이번 주도 모두 고생했어요!"; + private static final int REPORT_ROOM_BATCH_SIZE = 100; + + private final StudyRoomAccessService accessService; + private final StudyRoomRepository roomRepository; + private final StudyRoomMemberRepository memberRepository; + private final StudyRoomWeeklyReportRepository reportRepository; + private final StudyRoomWeeklyReportReadRepository readRepository; + private final StudyRoomChallengeRepository challengeRepository; + private final StudyRoomStatsService statsService; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List getReports(Long roomId, Long userId, int limit) { + accessService.validateMember(roomId, userId); + int safeLimit = Math.max(1, Math.min(limit, 12)); + List reports = reportRepository.findAllByRoomIdOrderByWeekStartDesc(roomId, PageRequest.of(0, safeLimit)); + Set readReportIds = readRepository.findAllByReportIdsAndUserId( + reports.stream().map(StudyRoomWeeklyReport::getId).toList(), userId) + .stream() + .map(read -> read.getReport().getId()) + .collect(Collectors.toSet()); + return reports.stream().map(report -> toResponse(report, readReportIds.contains(report.getId()))).toList(); + } + + @Transactional + public WeeklyReportReadResponse markRead(Long roomId, Long reportId, Long userId) { + accessService.validateMember(roomId, userId); + StudyRoomWeeklyReport report = reportRepository.findByIdAndRoomId(reportId, roomId) + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.REPORT_NOT_FOUND)); + if (readRepository.findByReportIdAndUserId(reportId, userId).isEmpty()) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + readRepository.save(StudyRoomWeeklyReportRead.create(report, user, LocalDateTime.now())); + } + return new WeeklyReportReadResponse(reportId, true); + } + + @Transactional + public void createPreviousWeekReports() { + LocalDate today = LocalDate.now(KST); + LocalDate weekStart = today.with(TemporalAdjusters.previous(DayOfWeek.MONDAY)); + LocalDate weekEnd = weekStart.plusDays(6); + LocalDateTime start = weekStart.atStartOfDay(); + LocalDateTime end = weekEnd.atTime(LocalTime.MAX); + Long cursor = 0L; + while (true) { + List rooms = roomRepository.findBatchAfterId(cursor, PageRequest.of(0, REPORT_ROOM_BATCH_SIZE)); + if (rooms.isEmpty()) { + return; + } + createReportsForBatch(rooms, weekStart, weekEnd, start, end); + cursor = rooms.get(rooms.size() - 1).getId(); + } + } + + void createReportIfAbsent(StudyRoom room, LocalDate weekStart, LocalDate weekEnd, LocalDateTime start, LocalDateTime end) { + List members = memberRepository.findAllWithUserByRoomId(room.getId()); + List userIds = members.stream().map(member -> member.getUser().getId()).toList(); + Map stats = statsService.getStats(userIds, start, end, weekEnd); + createReportIfAbsent(room, members, stats, weekStart, weekEnd, 0); + } + + private void createReportsForBatch(List rooms, LocalDate weekStart, LocalDate weekEnd, + LocalDateTime start, LocalDateTime end) { + List roomIds = rooms.stream().map(StudyRoom::getId).toList(); + List members = memberRepository.findAllWithRoomAndUserByRoomIds(roomIds); + Map> membersByRoomId = members.stream() + .collect(Collectors.groupingBy(member -> member.getRoom().getId(), LinkedHashMap::new, Collectors.toList())); + List userIds = members.stream().map(member -> member.getUser().getId()).distinct().toList(); + Map streaks = statsService.currentStreaks(userIds, weekEnd); + Map statsByUserId = statsService.getStats(userIds, start, end, streaks); + refreshChallengeStatuses(roomIds, membersByRoomId); + Map completedChallengeCounts = completedChallengeCounts(roomIds, start, end); + + for (StudyRoom room : rooms) { + List roomMembers = membersByRoomId.getOrDefault(room.getId(), List.of()); + Map roomStats = roomMembers.stream() + .collect(Collectors.toMap( + member -> member.getUser().getId(), + member -> statsByUserId.getOrDefault(member.getUser().getId(), new StudyRoomStats(0, 0, 0)) + )); + createReportIfAbsent(room, roomMembers, roomStats, weekStart, weekEnd, + completedChallengeCounts.getOrDefault(room.getId(), 0)); + } + } + + private void createReportIfAbsent(StudyRoom room, List members, Map stats, + LocalDate weekStart, LocalDate weekEnd, int challengesCompleted) { + if (reportRepository.existsByRoomIdAndWeekStart(room.getId(), weekStart)) { + return; + } + StudyRoomMember topProblemMember = members.stream() + .max(Comparator.comparingInt(member -> stats.getOrDefault(member.getUser().getId(), new StudyRoomStats(0, 0, 0)).weeklyProblemCount())) + .orElse(null); + StudyRoomMember topStreakMember = members.stream() + .max(Comparator.comparingInt(member -> stats.getOrDefault(member.getUser().getId(), new StudyRoomStats(0, 0, 0)).currentStreak())) + .orElse(null); + int topProblems = topProblemMember == null ? 0 : stats.getOrDefault(topProblemMember.getUser().getId(), new StudyRoomStats(0, 0, 0)).weeklyProblemCount(); + int longestStreak = topStreakMember == null ? 0 : stats.getOrDefault(topStreakMember.getUser().getId(), new StudyRoomStats(0, 0, 0)).currentStreak(); + int totalProblems = stats.values().stream().mapToInt(StudyRoomStats::weeklyProblemCount).sum(); + reportRepository.save(StudyRoomWeeklyReport.create( + room, + weekStart, + weekEnd, + topProblemMember == null ? null : topProblemMember.getUser().getName(), + topProblemMember == null ? null : topProblemMember.getUser().getProfileImageUrl(), + topProblems, + topStreakMember == null ? null : topStreakMember.getUser().getName(), + topStreakMember == null ? null : topStreakMember.getUser().getProfileImageUrl(), + longestStreak, + totalProblems, + challengesCompleted, + DEFAULT_CHEER_MESSAGE + )); + } + + private Map completedChallengeCounts(List roomIds, LocalDateTime start, LocalDateTime end) { + Map result = new HashMap<>(); + challengeRepository.countByRoomIdsAndStatusAndEndAtBetween(roomIds, StudyRoomChallengeStatus.COMPLETED, start, end) + .forEach(row -> result.put((Long) row[0], Math.toIntExact((Long) row[1]))); + return result; + } + + private void refreshChallengeStatuses(List roomIds, Map> membersByRoomId) { + List challenges = challengeRepository.findAllByRoomIdsAndStatus(roomIds, StudyRoomChallengeStatus.IN_PROGRESS); + for (StudyRoomChallenge challenge : challenges) { + List members = membersByRoomId.getOrDefault(challenge.getRoom().getId(), List.of()); + List userIds = members.stream().map(member -> member.getUser().getId()).toList(); + Map streaks = statsService.currentStreaks( + userIds, + min(LocalDate.now(KST), challenge.getEndAt().toLocalDate()), + challenge.getStartAt().toLocalDate() + ); + Map stats = statsService.getStats(userIds, challenge.getStartAt(), challenge.getEndAt(), streaks); + Map attendanceDays = isAttendanceMetric(challenge.getMetric()) + ? statsService.attendanceDayCounts(userIds, challenge.getStartAt(), challenge.getEndAt()) + : Map.of(); + if (isChallengeCompleted(challenge, members, stats, attendanceDays)) { + challenge.updateStatus(StudyRoomChallengeStatus.COMPLETED); + continue; + } + if (challenge.getEndAt().isBefore(LocalDateTime.now())) { + challenge.updateStatus(StudyRoomChallengeStatus.EXPIRED); + } + } + } + + private boolean isChallengeCompleted(StudyRoomChallenge challenge, List members, + Map stats, Map attendanceDays) { + if (members.isEmpty()) { + return false; + } + if (challenge.getType() == StudyRoomChallengeType.GROUP) { + int groupCurrent = members.stream() + .mapToInt(member -> metricValue(challenge.getMetric(), + stats.getOrDefault(member.getUser().getId(), new StudyRoomStats(0, 0, 0)), + attendanceDays.getOrDefault(member.getUser().getId(), 0))) + .sum(); + return groupCurrent >= challenge.getTargetValue(); + } + return members.stream() + .allMatch(member -> metricValue(challenge.getMetric(), + stats.getOrDefault(member.getUser().getId(), new StudyRoomStats(0, 0, 0)), + attendanceDays.getOrDefault(member.getUser().getId(), 0)) >= challenge.getTargetValue()); + } + + private int metricValue(StudyRoomChallengeMetric metric, StudyRoomStats stats, int attendanceDays) { + return switch (metric) { + case PROBLEM_COUNT, WEEKLY_PROBLEM_COUNT -> stats.weeklyProblemCount(); + case PRACTICE_COUNT, WEEKLY_PRACTICE_COUNT -> stats.weeklyPracticeCount(); + case ATTENDANCE, STREAK -> attendanceDays; + }; + } + + private boolean isAttendanceMetric(StudyRoomChallengeMetric metric) { + return metric == StudyRoomChallengeMetric.ATTENDANCE || metric == StudyRoomChallengeMetric.STREAK; + } + + private LocalDate min(LocalDate left, LocalDate right) { + return left.isBefore(right) ? left : right; + } + + private WeeklyReportResponse toResponse(StudyRoomWeeklyReport report, boolean read) { + return new WeeklyReportResponse(report.getId(), report.getWeekStart(), report.getWeekEnd(), + report.getTopMemberName(), report.getTopMemberProfileImageUrl(), report.getTopMemberProblemCount(), + report.getLongestStreakName(), report.getLongestStreakProfileImageUrl(), report.getLongestStreakDays(), + report.getTotalProblems(), report.getChallengesCompleted(), + report.getCheerMessage(), read); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/user/controller/UserController.java b/src/main/java/com/aisip/OnO/backend/user/controller/UserController.java index 316a80f5..78e409fa 100644 --- a/src/main/java/com/aisip/OnO/backend/user/controller/UserController.java +++ b/src/main/java/com/aisip/OnO/backend/user/controller/UserController.java @@ -7,8 +7,10 @@ import com.aisip.OnO.backend.user.dto.UserResponseDto; import com.aisip.OnO.backend.user.service.UserService; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @@ -23,9 +25,7 @@ public class UserController { public CommonResponse getUserInfo() { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); missionLogService.registerLoginMission(userId); - userService.touchLastActiveAt(userId); - - return CommonResponse.success(userService.findUser(userId)); + return CommonResponse.success(userService.touchAndFindUser(userId)); } // ✅ 사용자 정보 수정 @@ -47,10 +47,30 @@ public CommonResponse updateNotificationSettings( return CommonResponse.success("알림 설정이 변경되었습니다."); } + @PatchMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse updateProfileImage(@RequestParam("profileImage") MultipartFile profileImage) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(userService.updateProfileImage(userId, profileImage)); + } + + @PatchMapping(value = "/me/profile-image-url", consumes = MediaType.APPLICATION_JSON_VALUE) + public CommonResponse updateProfileImageByUrl(@RequestBody ProfileImageUrlRequest request) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(userService.updateProfileImageByUrl(userId, request.profileImageUrl())); + } + + @DeleteMapping("/me/profile-image") + public CommonResponse deleteProfileImage() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(userService.deleteProfileImage(userId)); + } + // ✅ 사용자 계정 삭제 @DeleteMapping("") public void deleteUserInfo() { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); userService.deleteUserById(userId); } + + public record ProfileImageUrlRequest(String profileImageUrl) {} } diff --git a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java index e80e5b4c..acdee406 100644 --- a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java @@ -12,6 +12,7 @@ public record UserResponseDto ( Long userId, String name, String email, + String profileImageUrl, Long attendanceLevel, Long attendancePoint, Long noteWriteLevel, @@ -35,6 +36,7 @@ public static UserResponseDto from(@NotNull User user) { .userId(user.getId()) .name(user.getName()) .email(user.getEmail()) + .profileImageUrl(user.getProfileImageUrl()) .attendanceLevel(getResponseLevel(missionStatus.getAttendanceLevel())) .attendancePoint(getResponsePoint(missionStatus.getAttendanceLevel(), missionStatus.getAttendancePoint())) .noteWriteLevel(getResponseLevel(missionStatus.getNoteWriteLevel())) diff --git a/src/main/java/com/aisip/OnO/backend/user/entity/User.java b/src/main/java/com/aisip/OnO/backend/user/entity/User.java index eb9090e1..ec4283ff 100644 --- a/src/main/java/com/aisip/OnO/backend/user/entity/User.java +++ b/src/main/java/com/aisip/OnO/backend/user/entity/User.java @@ -43,6 +43,9 @@ public class User extends BaseEntity { private String platform; + @Column(name = "profile_image_url") + private String profileImageUrl; + private LocalDateTime lastActiveAt; private LocalDate lastNotifiedAt; @@ -91,6 +94,10 @@ public void updateNotificationEnabled(boolean enabled) { this.notificationEnabled = enabled; } + public void updateProfileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } + public void updateUser(UserRegisterDto userRegisterDto) { if (userRegisterDto.email() != null && !userRegisterDto.email().isBlank()) { this.email = userRegisterDto.email(); diff --git a/src/main/java/com/aisip/OnO/backend/user/repository/UserRepository.java b/src/main/java/com/aisip/OnO/backend/user/repository/UserRepository.java index 8ac86f0f..b328c3fb 100644 --- a/src/main/java/com/aisip/OnO/backend/user/repository/UserRepository.java +++ b/src/main/java/com/aisip/OnO/backend/user/repository/UserRepository.java @@ -4,6 +4,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -22,6 +24,10 @@ public interface UserRepository extends JpaRepository, UserRepositor Optional findByName(String name); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from User u where u.id = :userId") + Optional findByIdForUpdate(@Param("userId") Long userId); + Page findAll(Pageable pageable); List findAllByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime startDateTime, LocalDateTime endDateTime); diff --git a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java index 34e89837..e4c5e0c1 100644 --- a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java +++ b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java @@ -4,20 +4,29 @@ import com.aisip.OnO.backend.folder.service.FolderService; import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentReactionRepository; import com.aisip.OnO.backend.user.dto.UserRegisterDto; import com.aisip.OnO.backend.user.dto.UserResponseDto; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; +import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; +import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.sql.Date; import java.sql.Timestamp; import java.time.LocalDate; @@ -25,6 +34,7 @@ import java.time.LocalTime; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; @@ -33,6 +43,7 @@ @Service @RequiredArgsConstructor public class UserService { + private static final long MAX_PROFILE_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; private final UserRepository userRepository; @@ -42,6 +53,14 @@ public class UserService { private final PracticeNoteService practiceNoteService; + private final StudyRoomSharedProblemCommentRepository sharedProblemCommentRepository; + + private final StudyRoomSharedProblemCommentReactionRepository sharedProblemCommentReactionRepository; + + private final FileUploadService fileUploadService; + + private final S3DeleteProducer s3DeleteProducer; + private final DiscordWebhookNotificationService discordWebhookNotificationService; private User findUserEntity(Long userId){ @@ -125,6 +144,13 @@ public void touchLastActiveAt(Long userId) { findUserEntity(userId).touchLastActiveAt(); } + @Transactional + public UserResponseDto touchAndFindUser(Long userId) { + User user = findUserEntity(userId); + user.touchLastActiveAt(); + return UserResponseDto.from(user); + } + @Transactional public void updateNotificationSettings(Long userId, boolean notificationEnabled) { findUserEntity(userId).updateNotificationEnabled(notificationEnabled); @@ -140,9 +166,44 @@ public void updateUser(Long userId, UserRegisterDto userRegisterDto) { userRepository.save(user); } + @Transactional + public UserResponseDto updateProfileImage(Long userId, MultipartFile profileImage) { + validateProfileImage(profileImage); + User user = findUserEntity(userId); + String previousProfileImageUrl = user.getProfileImageUrl(); + String profileImageUrl = fileUploadService.uploadFileToS3(profileImage); + deleteImageOnRollback(profileImageUrl, userId); + user.updateProfileImageUrl(profileImageUrl); + deleteImageAsync(previousProfileImageUrl, userId); + return UserResponseDto.from(user); + } + + @Transactional + public UserResponseDto updateProfileImageByUrl(Long userId, String imageUrl) { + fileUploadService.validateS3Url(imageUrl); + User user = findUserEntity(userId); + String previousProfileImageUrl = user.getProfileImageUrl(); + user.updateProfileImageUrl(imageUrl); + deleteImageAsync(previousProfileImageUrl, userId); + return UserResponseDto.from(user); + } + + @Transactional + public UserResponseDto deleteProfileImage(Long userId) { + User user = findUserEntity(userId); + String previousProfileImageUrl = user.getProfileImageUrl(); + user.updateProfileImageUrl(null); + deleteImageAsync(previousProfileImageUrl, userId); + return UserResponseDto.from(user); + } + @Transactional public void deleteUserById(Long userId) { User user = findUserEntity(userId); + deleteImageAsync(user.getProfileImageUrl(), userId); + sharedProblemCommentReactionRepository.deleteByCommentAuthorId(userId); + sharedProblemCommentReactionRepository.deleteByUserId(userId); + sharedProblemCommentRepository.deleteByAuthorId(userId); practiceNoteService.deleteAllPracticesByUser(userId); problemService.deleteAllUserProblems(userId); folderService.deleteAllUserFolders(userId); @@ -160,6 +221,111 @@ private String makeDeletedIdentifier(Long userId) { return "deleted:" + userId + ":" + UUID.randomUUID(); } + private void validateProfileImage(MultipartFile profileImage) { + if (profileImage == null || profileImage.isEmpty()) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + if (profileImage.getSize() > MAX_PROFILE_IMAGE_SIZE_BYTES) { + throw new ApplicationException(FileUploadErrorCase.FILE_SIZE_EXCEEDED); + } + + String extension = getLowercaseExtension(profileImage.getOriginalFilename()); + String contentType = profileImage.getContentType(); + if (!isAllowedImageType(contentType, extension) || !hasAllowedImageSignature(profileImage, contentType)) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + } + + private String getLowercaseExtension(String originalFilename) { + if (originalFilename == null) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + int dotIndex = originalFilename.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex == originalFilename.length() - 1) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + return originalFilename.substring(dotIndex + 1).toLowerCase(Locale.ROOT); + } + + private boolean isAllowedImageType(String contentType, String extension) { + if (contentType == null) { + return false; + } + return switch (contentType) { + case "image/jpeg" -> extension.equals("jpg") || extension.equals("jpeg"); + case "image/png" -> extension.equals("png"); + case "image/webp" -> extension.equals("webp"); + default -> false; + }; + } + + private boolean hasAllowedImageSignature(MultipartFile profileImage, String contentType) { + byte[] header = new byte[12]; + int read; + try { + read = profileImage.getInputStream().read(header); + } catch (IOException e) { + throw new ApplicationException(FileUploadErrorCase.FILE_UPLOAD_FAILED); + } + + return switch (contentType) { + case "image/jpeg" -> read >= 3 + && (header[0] & 0xFF) == 0xFF + && (header[1] & 0xFF) == 0xD8 + && (header[2] & 0xFF) == 0xFF; + case "image/png" -> read >= 8 + && (header[0] & 0xFF) == 0x89 + && header[1] == 0x50 + && header[2] == 0x4E + && header[3] == 0x47 + && header[4] == 0x0D + && header[5] == 0x0A + && header[6] == 0x1A + && header[7] == 0x0A; + case "image/webp" -> read >= 12 + && header[0] == 0x52 + && header[1] == 0x49 + && header[2] == 0x46 + && header[3] == 0x46 + && header[8] == 0x57 + && header[9] == 0x45 + && header[10] == 0x42 + && header[11] == 0x50; + default -> false; + }; + } + + private void deleteImageAsync(String imageUrl, Long userId) { + if (imageUrl == null || imageUrl.isBlank()) { + return; + } + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + s3DeleteProducer.sendDeleteMessage(imageUrl, userId); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + s3DeleteProducer.sendDeleteMessage(imageUrl, userId); + } + }); + } + + private void deleteImageOnRollback(String imageUrl, Long userId) { + if (imageUrl == null || imageUrl.isBlank() + || !TransactionSynchronizationManager.isSynchronizationActive()) { + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == STATUS_ROLLED_BACK) { + s3DeleteProducer.sendDeleteMessage(imageUrl, userId); + } + } + }); + } + private String makeGuestName() { return "Guest" + UUID.randomUUID().toString().substring(0, 8); } diff --git a/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java b/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java index 80cba693..3d6fd507 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java +++ b/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java @@ -1,6 +1,8 @@ package com.aisip.OnO.backend.util.fileupload.controller; +import com.aisip.OnO.backend.common.ratelimit.RateLimit; import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.util.fileupload.dto.PresignedUrlResponse; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -38,6 +40,19 @@ public CommonResponse> uploadMultipleImageFiles( return CommonResponse.success(imageUrls); } + // 앱이 S3에 직접 업로드할 수 있는 Presigned URL 발급 + // count: 발급할 URL 수 (최대 20) + // contentType: 업로드할 이미지 MIME 타입 (예: image/jpeg) + @RateLimit(key = "presigned_url", limitPerDay = 200) + @GetMapping("/presigned-urls") + public CommonResponse> getPresignedUrls( + @RequestParam(defaultValue = "1") int count, + @RequestParam(defaultValue = "image/jpeg") String contentType + ) { + int safeCount = Math.min(count, 20); + return CommonResponse.success(fileUploadService.generatePresignedUrls(contentType, safeCount)); + } + @DeleteMapping("/image") public CommonResponse deleteImageFile( @RequestParam("imageUrl") String imageUrl diff --git a/src/main/java/com/aisip/OnO/backend/util/fileupload/dto/PresignedUrlResponse.java b/src/main/java/com/aisip/OnO/backend/util/fileupload/dto/PresignedUrlResponse.java new file mode 100644 index 00000000..a2326fcd --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/util/fileupload/dto/PresignedUrlResponse.java @@ -0,0 +1,6 @@ +package com.aisip.OnO.backend.util.fileupload.dto; + +public record PresignedUrlResponse( + String presignedUrl, + String fileUrl +) {} diff --git a/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java b/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java index d0857280..fea6a326 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java @@ -10,7 +10,11 @@ public enum FileUploadErrorCase implements ErrorCase { FILE_UPLOAD_FAILED(400, 2001, "파일 업로드 중 문제가 발생했습니다."), - FILE_NOT_FOUND(404, 2002, "파일을 찾을 수 없습니다."); + FILE_NOT_FOUND(404, 2002, "파일을 찾을 수 없습니다."), + + INVALID_IMAGE_FILE(400, 2003, "이미지 파일 형식이 올바르지 않습니다."), + + FILE_SIZE_EXCEEDED(400, 2004, "파일 최대 용량을 초과했습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java b/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java index 8229f6f9..e23a8b32 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java +++ b/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java @@ -7,14 +7,20 @@ import com.aisip.OnO.backend.problemsolve.entity.ProblemSolveImageData; import com.aisip.OnO.backend.problemsolve.exception.ProblemSolveErrorCase; import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveImageDataRepository; +import com.aisip.OnO.backend.util.fileupload.dto.PresignedUrlResponse; import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; +import com.amazonaws.HttpMethod; import com.amazonaws.services.s3.AmazonS3Client; import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import com.amazonaws.services.s3.model.ObjectMetadata; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import java.io.IOException; +import java.util.Date; +import java.util.List; import java.util.Objects; +import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -60,6 +66,31 @@ public String uploadFileToS3(MultipartFile file) { return fileUrl; } + public void validateS3Url(String url) { + if (url == null || url.isBlank()) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + if (!url.startsWith("https://" + bucket + ".s3.")) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + } + + public List generatePresignedUrls(String contentType, int count) { + Date expiration = new Date(System.currentTimeMillis() + 10 * 60 * 1000L); // 10분 + + return IntStream.range(0, count) + .mapToObj(i -> { + String fileName = createFileNameForPresigned(contentType); + GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucket, fileName) + .withMethod(HttpMethod.PUT) + .withContentType(contentType) + .withExpiration(expiration); + String presignedUrl = amazonS3Client.generatePresignedUrl(request).toString(); + return new PresignedUrlResponse(presignedUrl, getFileUrl(fileName)); + }) + .toList(); + } + public void deleteImageFileFromS3(String imageUrl) { Timer.Sample sample = Timer.start(meterRegistry); String splitStr = ".com/"; @@ -108,13 +139,22 @@ private void deleteProblemSolveImageData(String imageUrl, Long userId) { private String createFileName(MultipartFile file) { String originalFilename = file.getOriginalFilename(); - String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); // 확장자 추출 + String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); - String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); // 날짜 기반 폴더 - String randomFileName = UUID.randomUUID().toString(); // 랜덤 UUID + String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + return "image/" + datePath + "/" + UUID.randomUUID() + extension; + } - // 예: image/YYYY/MM/DD/랜덤값.png - return "image/" + datePath + "/" + randomFileName + extension; + private String createFileNameForPresigned(String contentType) { + String extension = switch (contentType) { + case "image/png" -> ".png"; + case "image/gif" -> ".gif"; + case "image/webp" -> ".webp"; + case "image/heic", "image/heif" -> ".heic"; + default -> ".jpg"; // image/jpeg, image/jpg, application/octet-stream 등 + }; + String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + return "image/" + datePath + "/" + UUID.randomUUID() + extension; } diff --git a/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java b/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java index e74eb14a..9028f4d1 100644 --- a/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java +++ b/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java @@ -1,13 +1,9 @@ package com.aisip.OnO.backend.util.webhook; +import com.aisip.OnO.backend.config.rabbitmq.producer.DiscordWebhookProducer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; import java.time.Duration; import java.time.Instant; @@ -20,13 +16,10 @@ @RequiredArgsConstructor public class DiscordWebhookNotificationService { - private final RestTemplate restTemplate = new RestTemplate(); + private final DiscordWebhookProducer discordWebhookProducer; private final Map lastSentAtByDedupKey = new ConcurrentHashMap<>(); private static final Duration ERROR_NOTIFICATION_DEDUP_WINDOW = Duration.ofMinutes(5); - @Value("${discord.webhook-url}") - private String webhookUrl; - /** * 에러 발생 시 Discord로 알림 전송 */ @@ -51,7 +44,7 @@ public void sendErrorNotification(String path, String errorMessage, String statu ) ); - sendToDiscord(new DiscordWebhookPayload(null, List.of(embed)), dedupKey); + publishToQueue(new DiscordWebhookPayload(null, List.of(embed)), dedupKey); } /** @@ -67,7 +60,7 @@ public void sendMessage(String title, String message) { List.of() ); - sendToDiscord(new DiscordWebhookPayload(null, List.of(embed))); + publishToQueue(new DiscordWebhookPayload(null, List.of(embed))); } /** @@ -89,33 +82,21 @@ public void sendCustomEmbed(String title, String description, List(payload, headers), - String.class - ); - if (dedupKey != null) { - lastSentAtByDedupKey.put(dedupKey, Instant.now()); - } - log.info("Discord webhook 전송 완료: {}", payload.embeds().get(0).title()); - } catch (Exception e) { - log.error("Discord webhook 전송 실패: {}", e.getMessage(), e); - } + private void publishToQueue(DiscordWebhookPayload payload) { + publishToQueue(payload, null); } private boolean shouldSuppress(String dedupKey) { diff --git a/src/main/resources/db/migration/V10__add_shared_problem_comment.sql b/src/main/resources/db/migration/V10__add_shared_problem_comment.sql new file mode 100644 index 00000000..e7fb45b5 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_shared_problem_comment.sql @@ -0,0 +1,14 @@ +CREATE TABLE study_room_shared_problem_comment ( + id BIGINT NOT NULL AUTO_INCREMENT, + shared_problem_id BIGINT NOT NULL, + author_id BIGINT NOT NULL, + content VARCHAR(1000) NOT NULL, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT fk_shared_problem_comment_problem FOREIGN KEY (shared_problem_id) REFERENCES study_room_shared_problem (id) ON DELETE CASCADE, + CONSTRAINT fk_shared_problem_comment_author FOREIGN KEY (author_id) REFERENCES user (id) +); +CREATE INDEX idx_shared_problem_comment_problem_id ON study_room_shared_problem_comment (shared_problem_id, id); +CREATE INDEX idx_shared_problem_comment_author ON study_room_shared_problem_comment (author_id); diff --git a/src/main/resources/db/migration/V11__add_comment_reactions_and_user_profile_image.sql b/src/main/resources/db/migration/V11__add_comment_reactions_and_user_profile_image.sql new file mode 100644 index 00000000..365de366 --- /dev/null +++ b/src/main/resources/db/migration/V11__add_comment_reactions_and_user_profile_image.sql @@ -0,0 +1,18 @@ +ALTER TABLE user + ADD COLUMN profile_image_url VARCHAR(255); + +CREATE TABLE study_room_shared_problem_comment_reaction ( + id BIGINT NOT NULL AUTO_INCREMENT, + comment_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + emoji VARCHAR(80) NOT NULL, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT uk_shared_problem_comment_reaction UNIQUE (comment_id, user_id, emoji), + CONSTRAINT fk_shared_problem_comment_reaction_comment FOREIGN KEY (comment_id) REFERENCES study_room_shared_problem_comment (id) ON DELETE CASCADE, + CONSTRAINT fk_shared_problem_comment_reaction_user FOREIGN KEY (user_id) REFERENCES user (id) +); +CREATE INDEX idx_shared_problem_comment_reaction_comment ON study_room_shared_problem_comment_reaction (comment_id); +CREATE INDEX idx_shared_problem_comment_reaction_user ON study_room_shared_problem_comment_reaction (user_id); diff --git a/src/main/resources/db/migration/V12__add_challenge_period_days.sql b/src/main/resources/db/migration/V12__add_challenge_period_days.sql new file mode 100644 index 00000000..66978924 --- /dev/null +++ b/src/main/resources/db/migration/V12__add_challenge_period_days.sql @@ -0,0 +1 @@ +ALTER TABLE study_room_challenge ADD COLUMN period_days INT NULL; diff --git a/src/main/resources/db/migration/V13__drop_study_session.sql b/src/main/resources/db/migration/V13__drop_study_session.sql new file mode 100644 index 00000000..a5c32e84 --- /dev/null +++ b/src/main/resources/db/migration/V13__drop_study_session.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS study_session; diff --git a/src/main/resources/db/migration/V14__delete_session_started_feeds.sql b/src/main/resources/db/migration/V14__delete_session_started_feeds.sql new file mode 100644 index 00000000..9a56a628 --- /dev/null +++ b/src/main/resources/db/migration/V14__delete_session_started_feeds.sql @@ -0,0 +1 @@ +DELETE FROM study_room_feed WHERE event_type = 'SESSION_STARTED'; diff --git a/src/main/resources/db/migration/V15__add_challenge_completed_at.sql b/src/main/resources/db/migration/V15__add_challenge_completed_at.sql new file mode 100644 index 00000000..72e8a4b1 --- /dev/null +++ b/src/main/resources/db/migration/V15__add_challenge_completed_at.sql @@ -0,0 +1,2 @@ +ALTER TABLE study_room_challenge + ADD COLUMN completed_at DATETIME NULL COMMENT '챌린지 완료 시각 (최초 완료 시 1회만 기록)'; diff --git a/src/main/resources/db/migration/V16__add_weekly_report_profile_image_urls.sql b/src/main/resources/db/migration/V16__add_weekly_report_profile_image_urls.sql new file mode 100644 index 00000000..3b4d2787 --- /dev/null +++ b/src/main/resources/db/migration/V16__add_weekly_report_profile_image_urls.sql @@ -0,0 +1,3 @@ +ALTER TABLE study_room_weekly_report + ADD COLUMN top_member_profile_image_url VARCHAR(512) NULL, + ADD COLUMN longest_streak_profile_image_url VARCHAR(512) NULL; diff --git a/src/main/resources/db/migration/V17__add_refresh_token_index.sql b/src/main/resources/db/migration/V17__add_refresh_token_index.sql new file mode 100644 index 00000000..6d778975 --- /dev/null +++ b/src/main/resources/db/migration/V17__add_refresh_token_index.sql @@ -0,0 +1,2 @@ +ALTER TABLE refresh_token + ADD INDEX idx_refresh_token_token (refresh_token(255)); diff --git a/src/main/resources/db/migration/V6__add_study_room_schema.sql b/src/main/resources/db/migration/V6__add_study_room_schema.sql new file mode 100644 index 00000000..ed19705a --- /dev/null +++ b/src/main/resources/db/migration/V6__add_study_room_schema.sql @@ -0,0 +1,173 @@ +CREATE TABLE study_room ( + id BIGINT NOT NULL AUTO_INCREMENT, + version BIGINT, + name VARCHAR(20) NOT NULL, + host_user_id BIGINT NOT NULL, + thumbnail_url VARCHAR(255), + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id) +); +CREATE INDEX idx_study_room_host_user ON study_room (host_user_id); + +CREATE TABLE study_room_member ( + id BIGINT NOT NULL AUTO_INCREMENT, + room_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role VARCHAR(20) NOT NULL, + weekly_goal INT, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT uk_study_room_member_room_user UNIQUE (room_id, user_id), + CONSTRAINT fk_study_room_member_room FOREIGN KEY (room_id) REFERENCES study_room (id) ON DELETE CASCADE, + CONSTRAINT fk_study_room_member_user FOREIGN KEY (user_id) REFERENCES user (id) +); +CREATE INDEX idx_study_room_member_user ON study_room_member (user_id); +CREATE INDEX idx_study_room_member_room ON study_room_member (room_id); + +CREATE TABLE study_room_invite_code ( + id BIGINT NOT NULL AUTO_INCREMENT, + room_id BIGINT NOT NULL, + code VARCHAR(6) NOT NULL, + expired_at DATETIME(6) NOT NULL, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT uk_study_room_invite_code UNIQUE (code), + CONSTRAINT fk_study_room_invite_room FOREIGN KEY (room_id) REFERENCES study_room (id) ON DELETE CASCADE +); +CREATE INDEX idx_study_room_invite_room ON study_room_invite_code (room_id); + +CREATE TABLE study_room_feed ( + id BIGINT NOT NULL AUTO_INCREMENT, + room_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + event_type VARCHAR(40) NOT NULL, + metadata_json TEXT, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT fk_study_room_feed_room FOREIGN KEY (room_id) REFERENCES study_room (id) ON DELETE CASCADE, + CONSTRAINT fk_study_room_feed_user FOREIGN KEY (user_id) REFERENCES user (id) +); +CREATE INDEX idx_study_room_feed_room_created ON study_room_feed (room_id, created_at); +CREATE INDEX idx_study_room_feed_room_user_type_created ON study_room_feed (room_id, user_id, event_type, created_at); + +CREATE TABLE study_room_feed_reaction ( + id BIGINT NOT NULL AUTO_INCREMENT, + feed_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + emoji VARCHAR(16) NOT NULL, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT uk_study_room_feed_reaction UNIQUE (feed_id, user_id, emoji), + CONSTRAINT fk_study_room_feed_reaction_feed FOREIGN KEY (feed_id) REFERENCES study_room_feed (id) ON DELETE CASCADE, + CONSTRAINT fk_study_room_feed_reaction_user FOREIGN KEY (user_id) REFERENCES user (id) +); + +CREATE TABLE study_room_challenge ( + id BIGINT NOT NULL AUTO_INCREMENT, + room_id BIGINT NOT NULL, + title VARCHAR(40) NOT NULL, + type VARCHAR(20) NOT NULL, + metric VARCHAR(40) NOT NULL, + target_value INT NOT NULL, + start_at DATETIME(6) NOT NULL, + end_at DATETIME(6) NOT NULL, + status VARCHAR(20) NOT NULL, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT fk_study_room_challenge_room FOREIGN KEY (room_id) REFERENCES study_room (id) ON DELETE CASCADE +); +CREATE INDEX idx_study_room_challenge_room_status_end ON study_room_challenge (room_id, status, end_at); + +CREATE TABLE study_session ( + id BIGINT NOT NULL AUTO_INCREMENT, + room_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + started_at DATETIME(6) NOT NULL, + ended_at DATETIME(6), + duration_minutes INT, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT fk_study_session_room FOREIGN KEY (room_id) REFERENCES study_room (id) ON DELETE CASCADE, + CONSTRAINT fk_study_session_user FOREIGN KEY (user_id) REFERENCES user (id) +); +CREATE INDEX idx_study_session_room_ended ON study_session (room_id, ended_at); +CREATE INDEX idx_study_session_user_ended ON study_session (user_id, ended_at); + +CREATE TABLE study_room_shared_problem ( + id BIGINT NOT NULL AUTO_INCREMENT, + room_id BIGINT NOT NULL, + shared_by_user_id BIGINT NOT NULL, + problem_id BIGINT NOT NULL, + comment VARCHAR(100), + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT fk_study_room_shared_problem_room FOREIGN KEY (room_id) REFERENCES study_room (id) ON DELETE CASCADE, + CONSTRAINT fk_study_room_shared_problem_user FOREIGN KEY (shared_by_user_id) REFERENCES user (id), + CONSTRAINT fk_study_room_shared_problem_problem FOREIGN KEY (problem_id) REFERENCES problem (id) +); +CREATE INDEX idx_study_room_shared_problem_room_created ON study_room_shared_problem (room_id, created_at); +CREATE INDEX idx_study_room_shared_problem_problem ON study_room_shared_problem (problem_id); + +CREATE TABLE study_room_shared_problem_reaction ( + id BIGINT NOT NULL AUTO_INCREMENT, + shared_problem_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + emoji VARCHAR(16) NOT NULL, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT uk_study_room_shared_problem_reaction UNIQUE (shared_problem_id, user_id, emoji), + CONSTRAINT fk_study_room_shared_problem_reaction_shared FOREIGN KEY (shared_problem_id) REFERENCES study_room_shared_problem (id) ON DELETE CASCADE, + CONSTRAINT fk_study_room_shared_problem_reaction_user FOREIGN KEY (user_id) REFERENCES user (id) +); + +CREATE TABLE study_room_weekly_report ( + id BIGINT NOT NULL AUTO_INCREMENT, + room_id BIGINT NOT NULL, + week_start DATE NOT NULL, + week_end DATE NOT NULL, + top_member_name VARCHAR(255), + top_member_problem_count INT NOT NULL, + longest_streak_name VARCHAR(255), + longest_streak_days INT NOT NULL, + total_problems INT NOT NULL, + challenges_completed INT NOT NULL, + cheer_message VARCHAR(255) NOT NULL, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT uk_study_room_weekly_report UNIQUE (room_id, week_start), + CONSTRAINT fk_study_room_weekly_report_room FOREIGN KEY (room_id) REFERENCES study_room (id) ON DELETE CASCADE +); + +CREATE TABLE study_room_weekly_report_read ( + id BIGINT NOT NULL AUTO_INCREMENT, + report_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + read_at DATETIME(6) NOT NULL, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT uk_study_room_weekly_report_read UNIQUE (report_id, user_id), + CONSTRAINT fk_study_room_weekly_report_read_report FOREIGN KEY (report_id) REFERENCES study_room_weekly_report (id) ON DELETE CASCADE, + CONSTRAINT fk_study_room_weekly_report_read_user FOREIGN KEY (user_id) REFERENCES user (id) +); diff --git a/src/main/resources/db/migration/V7__add_custom_emoji_fields.sql b/src/main/resources/db/migration/V7__add_custom_emoji_fields.sql new file mode 100644 index 00000000..0290ae10 --- /dev/null +++ b/src/main/resources/db/migration/V7__add_custom_emoji_fields.sql @@ -0,0 +1,16 @@ +ALTER TABLE practice_note + ADD COLUMN last_session_mood_emoji_key VARCHAR(80) NULL; + +CREATE TABLE learning_calendar_mood ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + study_date DATE NOT NULL, + emoji_key VARCHAR(80) NOT NULL, + created_at DATETIME(6), + updated_at DATETIME(6), + deleted_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT uk_learning_calendar_mood_user_date UNIQUE (user_id, study_date), + CONSTRAINT fk_learning_calendar_mood_user FOREIGN KEY (user_id) REFERENCES user (id) +); +CREATE INDEX idx_learning_calendar_mood_user_date ON learning_calendar_mood (user_id, study_date); diff --git a/src/main/resources/db/migration/V8__add_study_room_challenge_period.sql b/src/main/resources/db/migration/V8__add_study_room_challenge_period.sql new file mode 100644 index 00000000..643e8298 --- /dev/null +++ b/src/main/resources/db/migration/V8__add_study_room_challenge_period.sql @@ -0,0 +1,2 @@ +ALTER TABLE study_room_challenge + ADD COLUMN period VARCHAR(20) NULL AFTER metric; diff --git a/src/main/resources/db/migration/V9__widen_study_room_reaction_emoji.sql b/src/main/resources/db/migration/V9__widen_study_room_reaction_emoji.sql new file mode 100644 index 00000000..0be514a0 --- /dev/null +++ b/src/main/resources/db/migration/V9__widen_study_room_reaction_emoji.sql @@ -0,0 +1,5 @@ +ALTER TABLE study_room_feed_reaction + MODIFY COLUMN emoji VARCHAR(80) NOT NULL; + +ALTER TABLE study_room_shared_problem_reaction + MODIFY COLUMN emoji VARCHAR(80) NOT NULL; \ No newline at end of file diff --git a/src/main/resources/static/images/ScreenShot/Emoji.png b/src/main/resources/static/images/ScreenShot/Emoji.png new file mode 100644 index 00000000..b3768b75 Binary files /dev/null and b/src/main/resources/static/images/ScreenShot/Emoji.png differ diff --git a/src/main/resources/static/images/ScreenShot/StudyRoomActivity.png b/src/main/resources/static/images/ScreenShot/StudyRoomActivity.png new file mode 100644 index 00000000..b0969101 Binary files /dev/null and b/src/main/resources/static/images/ScreenShot/StudyRoomActivity.png differ diff --git a/src/main/resources/static/images/ScreenShot/StudyRoomChallenge.png b/src/main/resources/static/images/ScreenShot/StudyRoomChallenge.png new file mode 100644 index 00000000..e7975ce6 Binary files /dev/null and b/src/main/resources/static/images/ScreenShot/StudyRoomChallenge.png differ diff --git a/src/main/resources/static/images/ScreenShot/StudyRoomRanking.png b/src/main/resources/static/images/ScreenShot/StudyRoomRanking.png new file mode 100644 index 00000000..5e23ddfc Binary files /dev/null and b/src/main/resources/static/images/ScreenShot/StudyRoomRanking.png differ diff --git a/src/main/resources/static/images/ScreenShot/StudyRoomReport.png b/src/main/resources/static/images/ScreenShot/StudyRoomReport.png new file mode 100644 index 00000000..3a1734ca Binary files /dev/null and b/src/main/resources/static/images/ScreenShot/StudyRoomReport.png differ diff --git a/src/main/resources/static/images/ScreenShot/StudyRoomShare.png b/src/main/resources/static/images/ScreenShot/StudyRoomShare.png new file mode 100644 index 00000000..69dcdceb Binary files /dev/null and b/src/main/resources/static/images/ScreenShot/StudyRoomShare.png differ diff --git a/src/main/resources/static/images/ScreenShot/StudyRoomShareComment.png b/src/main/resources/static/images/ScreenShot/StudyRoomShareComment.png new file mode 100644 index 00000000..948efe14 Binary files /dev/null and b/src/main/resources/static/images/ScreenShot/StudyRoomShareComment.png differ diff --git a/src/main/resources/static/images/ScreenShot/StudyRoomShareDetail.png b/src/main/resources/static/images/ScreenShot/StudyRoomShareDetail.png new file mode 100644 index 00000000..96415958 Binary files /dev/null and b/src/main/resources/static/images/ScreenShot/StudyRoomShareDetail.png differ diff --git a/src/main/resources/static/images/ScreenShot/StudyRoomThumbnail.png b/src/main/resources/static/images/ScreenShot/StudyRoomThumbnail.png new file mode 100644 index 00000000..14d13341 Binary files /dev/null and b/src/main/resources/static/images/ScreenShot/StudyRoomThumbnail.png differ diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index d2fbc7be..28d85713 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -153,7 +153,7 @@ .gallery-track { display: flex; - gap: 18px; + gap: 34px; overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; @@ -283,7 +283,7 @@ display: flex; align-items: flex-end; justify-content: center; - gap: 12px; + gap: 24px; } .phone-duo .phone-img { max-width: 188px; } .phone-duo .phone-img:last-child { @@ -367,6 +367,65 @@ font-size: .83rem; color: var(--t3); } + /* ── STUDY ROOM SHOWCASE ── */ + .study-showcase { + display: flex; + flex-direction: column; + align-items: center; + gap: 28px; + } + .study-phone-duo .phone-img { max-width: 178px; } + .study-phone-duo .phone-img:last-child { + transform: translateY(34px); + } + .study-cards { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + width: 100%; + max-width: 430px; + margin-top: 28px; + } + .study-card { + display: grid; + grid-template-columns: auto 1fr; + column-gap: 14px; + align-items: flex-start; + background: #fff; + border: 1px solid var(--line); + border-radius: 14px; + padding: 16px 18px; + } + .study-card-kicker { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + min-width: 42px; + padding: 0 9px; + border-radius: 999px; + background: rgba(28, 186, 26, .08); + color: var(--brand-dk); + font-family: "Space Grotesk", sans-serif; + font-size: .72rem; + font-weight: 700; + grid-row: 1 / span 2; + } + .study-card strong { + display: block; + font-size: .96rem; + font-weight: 700; + color: var(--t1); + margin-bottom: 7px; + } + .study-card p { + font-size: .83rem; + color: var(--t2); + line-height: 1.62; + word-break: keep-all; + margin: 0; + } + /* ── RESPONSIVE ── */ @media (max-width: 860px) { .feat-inner { @@ -378,7 +437,13 @@ .feat-copy .band-p { max-width: none; } .feat-img-wrap .phone-img { max-width: 240px; } .phone-duo .phone-img { max-width: 155px; } + .study-phone-duo .phone-img { max-width: 148px; } + .phone-duo { gap: 16px; } .stat-row { justify-content: center; } + .study-cards { + max-width: none; + margin-top: 26px; + } .achieve-inner { grid-template-columns: 1fr; @@ -471,6 +536,12 @@

틀린 문제, 이제
OnO로 끝내세요

학습 레벨
+
+ 스터디룸 챌린지 +
+
+ 스터디룸 공유 피드 +
학습 달력
@@ -625,6 +696,45 @@

꾸준함이
실력이 됩니다

+ +
+
+
+ 스터디룸 +

함께 공부하면
더 잘 됩니다

+

+ 친구들과 스터디룸을 만들고, 챌린지와 피드로 서로의 학습을 응원하세요. + 혼자보다 훨씬 멀리 갈 수 있습니다. +

+
+
+ 01 + 함께 공부 +

친구를 초대해 같은 목표를 공유하고 학습 흐름을 이어가요.

+
+
+ 02 + 챌린지 +

문제 등록과 복습 목표를 정하고 멤버들과 함께 달성해요.

+
+
+ 03 + 랭킹·리포트 +

멤버별 학습 기록과 주간 성과를 한눈에 비교해요.

+
+
+
+
+
+
+ 스터디룸 챌린지 화면 + 스터디룸 공유 피드 화면 +
+
+
+
+
+
diff --git a/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java index 3724c2fe..968b7136 100644 --- a/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java @@ -158,8 +158,10 @@ public void getFolderTest_Root() throws Exception { .andExpect(jsonPath("$.data.subFolderList.length()").value(folder.getSubFolderList().size())) .andExpect(jsonPath("$.data.subFolderList[0].folderId").value(folder.getSubFolderList().get(0).getId())) .andExpect(jsonPath("$.data.subFolderList[0].folderName").value(folder.getSubFolderList().get(0).getName())) + .andExpect(jsonPath("$.data.subFolderList[0].problemCount").value(folder.getSubFolderList().get(0).getProblemList().size())) .andExpect(jsonPath("$.data.subFolderList[1].folderId").value(folder.getSubFolderList().get(1).getId())) .andExpect(jsonPath("$.data.subFolderList[1].folderName").value(folder.getSubFolderList().get(1).getName())) + .andExpect(jsonPath("$.data.subFolderList[1].problemCount").value(folder.getSubFolderList().get(1).getProblemList().size())) .andExpect(jsonPath("$.data.problemIdList.length()").value(folder.getProblemList().size())) .andExpect(jsonPath("$.data.problemIdList[0]").value(folder.getProblemList().get(0).getId())) .andReturn(); @@ -181,11 +183,14 @@ public void getFolderTest_Internal() throws Exception { .andExpect(jsonPath("$.data.folderId").value(folder.getId())) .andExpect(jsonPath("$.data.folderName").value(folder.getName())) .andExpect(jsonPath("$.data.parentFolder.folderId").value(folder.getParentFolder().getId())) + .andExpect(jsonPath("$.data.parentFolder.problemCount").value(folder.getParentFolder().getProblemList().size())) .andExpect(jsonPath("$.data.subFolderList.length()").value(folder.getSubFolderList().size())) .andExpect(jsonPath("$.data.subFolderList[0].folderId").value(folder.getSubFolderList().get(0).getId())) .andExpect(jsonPath("$.data.subFolderList[0].folderName").value(folder.getSubFolderList().get(0).getName())) + .andExpect(jsonPath("$.data.subFolderList[0].problemCount").value(folder.getSubFolderList().get(0).getProblemList().size())) .andExpect(jsonPath("$.data.subFolderList[1].folderId").value(folder.getSubFolderList().get(1).getId())) .andExpect(jsonPath("$.data.subFolderList[1].folderName").value(folder.getSubFolderList().get(1).getName())) + .andExpect(jsonPath("$.data.subFolderList[1].problemCount").value(folder.getSubFolderList().get(1).getProblemList().size())) .andExpect(jsonPath("$.data.problemIdList.length()").value(folder.getProblemList().size())) .andExpect(jsonPath("$.data.problemIdList[0]").value(folder.getProblemList().get(0).getId())) .andReturn(); @@ -207,6 +212,7 @@ public void getFolderTest_External() throws Exception { .andExpect(jsonPath("$.data.folderId").value(folder.getId())) .andExpect(jsonPath("$.data.folderName").value(folder.getName())) .andExpect(jsonPath("$.data.parentFolder.folderId").value(folder.getParentFolder().getId())) + .andExpect(jsonPath("$.data.parentFolder.problemCount").value(folder.getParentFolder().getProblemList().size())) .andExpect(jsonPath("$.data.subFolderList.length()").value(0)) .andExpect(jsonPath("$.data.problemIdList.length()").value(folder.getProblemList().size())) .andExpect(jsonPath("$.data.problemIdList[0]").value(folder.getProblemList().get(0).getId())) @@ -233,8 +239,10 @@ public void getRootFolderTest_Exist() throws Exception { .andExpect(jsonPath("$.data.problemIdList.length()").value(rootFolder.getProblemList().size())) .andExpect(jsonPath("$.data.subFolderList[0].folderId").value(rootFolder.getSubFolderList().get(0).getId())) .andExpect(jsonPath("$.data.subFolderList[0].folderName").value(rootFolder.getSubFolderList().get(0).getName())) + .andExpect(jsonPath("$.data.subFolderList[0].problemCount").value(rootFolder.getSubFolderList().get(0).getProblemList().size())) .andExpect(jsonPath("$.data.subFolderList[1].folderId").value(rootFolder.getSubFolderList().get(1).getId())) .andExpect(jsonPath("$.data.subFolderList[1].folderName").value(rootFolder.getSubFolderList().get(1).getName())) + .andExpect(jsonPath("$.data.subFolderList[1].problemCount").value(rootFolder.getSubFolderList().get(1).getProblemList().size())) .andExpect(jsonPath("$.data.problemIdList[0]").value(rootFolder.getProblemList().get(0).getId())) .andReturn(); @@ -276,6 +284,7 @@ public void getAllUserFolderThumbnails_Test() throws Exception { Folder folder = folderList.get(i); assertThat(folder.getId().intValue()).isEqualTo( JsonPath.read(content, "$.data[" + i + "].folderId")); assertThat(folder.getName()).isEqualTo(JsonPath.read(content, "$.data[" + i + "].folderName")); + assertThat(folder.getProblemList().size()).isEqualTo(JsonPath.read(content, "$.data[" + i + "].problemCount")); } System.out.println("==== 응답 결과 ===="); @@ -306,6 +315,10 @@ public void getAllUserFolderDetails_Test() throws Exception { } assertThat(folder.getSubFolderList().size()).isEqualTo(JsonPath.read(content, "$.data[" + i + "].subFolderList.length()")); assertThat(folder.getProblemList().size()).isEqualTo(JsonPath.read(content, "$.data[" + i + "].problemIdList.length()")); + for (int j = 0; j < folder.getSubFolderList().size(); j++) { + assertThat(folder.getSubFolderList().get(j).getProblemList().size()) + .isEqualTo(JsonPath.read(content, "$.data[" + i + "].subFolderList[" + j + "].problemCount")); + } } System.out.println("==== 응답 결과 ===="); diff --git a/src/test/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryTest.java b/src/test/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryTest.java index 70bdeff0..9be89e43 100644 --- a/src/test/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryTest.java +++ b/src/test/java/com/aisip/OnO/backend/folder/repository/FolderRepositoryTest.java @@ -24,7 +24,9 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -249,4 +251,22 @@ void findAllFoldersWithDetailsByUserIdTest () { assertThat(0L).isEqualTo(1L); } } -} \ No newline at end of file + + @Test + @DisplayName("폴더별 문제 수 집계 테스트") + void countProblemsByFolderIdsTest() { + List folderIds = folderList.stream() + .map(Folder::getId) + .toList(); + + Map problemCountsByFolderId = folderRepository.countProblemsByFolderIds(folderIds).stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + + for (Folder folder : folderList) { + assertThat(problemCountsByFolderId.get(folder.getId())).isEqualTo(2L); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/folder/service/FolderServiceTest.java b/src/test/java/com/aisip/OnO/backend/folder/service/FolderServiceTest.java index 66e0acc0..ca1140e8 100644 --- a/src/test/java/com/aisip/OnO/backend/folder/service/FolderServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/folder/service/FolderServiceTest.java @@ -157,7 +157,9 @@ void findRootFolder() { assertThat(folderResponseDto.folderName()).isEqualTo(folderList.get(0).getName()); assertThat(folderResponseDto.parentFolder()).isNull(); assertThat(folderResponseDto.subFolderList().get(0).folderId()).isEqualTo(folderList.get(1).getId()); + assertThat(folderResponseDto.subFolderList().get(0).problemCount()).isEqualTo((long) folderList.get(1).getProblemList().size()); assertThat(folderResponseDto.subFolderList().get(1).folderId()).isEqualTo(folderList.get(2).getId()); + assertThat(folderResponseDto.subFolderList().get(1).problemCount()).isEqualTo((long) folderList.get(2).getProblemList().size()); assertThat(folderResponseDto.problemIdList().get(0)).isEqualTo(problemList.get(0).problemId()); assertThat(folderResponseDto.problemIdList().get(1)).isEqualTo(problemList.get(1).problemId()); } @@ -176,8 +178,11 @@ void findFolder() { assertThat(folderResponseDto.folderId()).isEqualTo(folderList.get(1).getId()); assertThat(folderResponseDto.folderName()).isEqualTo(folderList.get(1).getName()); assertThat(folderResponseDto.parentFolder().folderId()).isEqualTo(parentFolderId); + assertThat(folderResponseDto.parentFolder().problemCount()).isEqualTo((long) folderList.get(0).getProblemList().size()); assertThat(folderResponseDto.subFolderList().get(0).folderId()).isEqualTo(folderList.get(3).getId()); + assertThat(folderResponseDto.subFolderList().get(0).problemCount()).isEqualTo((long) folderList.get(3).getProblemList().size()); assertThat(folderResponseDto.subFolderList().get(1).folderId()).isEqualTo(folderList.get(4).getId()); + assertThat(folderResponseDto.subFolderList().get(1).problemCount()).isEqualTo((long) folderList.get(4).getProblemList().size()); assertThat(folderResponseDto.problemIdList().get(0)).isEqualTo(problemList.get(2).problemId()); assertThat(folderResponseDto.problemIdList().get(1)).isEqualTo(problemList.get(3).problemId()); } @@ -222,6 +227,7 @@ void findAllUserFolderThumbnails() { for (int i = 0; i < folderThumbnailResponseDtoList.size(); i++) { assertThat(folderThumbnailResponseDtoList.get(i).folderId()).isEqualTo(folderList.get(i).getId()); assertThat(folderThumbnailResponseDtoList.get(i).folderName()).isEqualTo(folderList.get(i).getName()); + assertThat(folderThumbnailResponseDtoList.get(i).problemCount()).isEqualTo((long) folderList.get(i).getProblemList().size()); } } diff --git a/src/test/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarControllerTest.java b/src/test/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarControllerTest.java index bb06ec18..c1aeb064 100644 --- a/src/test/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarControllerTest.java @@ -20,6 +20,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -53,6 +54,7 @@ void getLearningCalendar() throws Exception { .noteWriteCount(1) .studyMinutes(12) .reviewedItems(List.of("이차방정식 오답노트")) + .moodEmojiKey("happy_tears") .build())) .build(); given(learningCalendarService.getLearningCalendar(7L, 2026, 5)).willReturn(response); @@ -72,11 +74,37 @@ void getLearningCalendar() throws Exception { .andExpect(jsonPath("$.data.records[0].reviewCount").value(2)) .andExpect(jsonPath("$.data.records[0].noteWriteCount").value(1)) .andExpect(jsonPath("$.data.records[0].studyMinutes").value(12)) - .andExpect(jsonPath("$.data.records[0].reviewedItems[0]").value("이차방정식 오답노트")); + .andExpect(jsonPath("$.data.records[0].reviewedItems[0]").value("이차방정식 오답노트")) + .andExpect(jsonPath("$.data.records[0].moodEmojiKey").value("happy_tears")); verify(learningCalendarService).getLearningCalendar(7L, 2026, 5); } + @Test + @DisplayName("학습 달력 감정 이모지 저장") + @WithMockCustomUser(userId = 7L) + void updateMood() throws Exception { + given(learningCalendarService.updateMood(7L, new com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarMoodRequestDto( + LocalDate.of(2026, 6, 7), + "happy_tears" + ))).willReturn(new com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarMoodResponseDto( + LocalDate.of(2026, 6, 7), + "happy_tears" + )); + + mockMvc.perform(patch("/api/learning-calendar/mood") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "date": "2026-06-07", + "emojiKey": "happy_tears" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.date").value("2026-06-07")) + .andExpect(jsonPath("$.data.emojiKey").value("happy_tears")); + } + @Test @DisplayName("학습 달력 조회 - 잘못된 월은 400을 반환한다") @WithMockCustomUser(userId = 7L) diff --git a/src/test/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarServiceTest.java b/src/test/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarServiceTest.java index 9bf8d020..b546de73 100644 --- a/src/test/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarServiceTest.java @@ -1,6 +1,9 @@ package com.aisip.OnO.backend.learningcalendar.service; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarMoodRequestDto; import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarResponseDto; +import com.aisip.OnO.backend.learningcalendar.exception.LearningCalendarErrorCase; import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; import com.aisip.OnO.backend.problem.entity.Problem; import com.aisip.OnO.backend.problem.repository.ProblemRepository; @@ -24,6 +27,7 @@ import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest @ActiveProfiles("test") @@ -96,6 +100,42 @@ void getLearningCalendar_success() { assertThat(records.get(LocalDate.of(2026, 5, 2)).noteWriteCount()).isEqualTo(1); assertThat(records.get(LocalDate.of(2026, 5, 6)).hasStudied()).isFalse(); assertThat(records.get(LocalDate.of(2026, 5, 6)).reviewedItems()).isEmpty(); + assertThat(records.get(LocalDate.of(2026, 5, 1)).moodEmojiKey()).isNull(); + } + + @Test + @DisplayName("학습 기록이 있는 날짜에 감정 이모지를 저장하고 조회한다") + void updateMood_success() { + User targetUser = createUser("calendar-mood-user"); + Long userId = targetUser.getId(); + createProblemWrite(userId, LocalDateTime.of(2026, 6, 7, 8, 0)); + + learningCalendarService.updateMood(userId, + new LearningCalendarMoodRequestDto(LocalDate.of(2026, 6, 7), "happy_tears")); + learningCalendarService.updateMood(userId, + new LearningCalendarMoodRequestDto(LocalDate.of(2026, 6, 7), "success_checkmark")); + + LearningCalendarResponseDto response = learningCalendarService.getLearningCalendar( + userId, + 2026, + 6, + LocalDate.of(2026, 6, 8) + ); + + Map records = response.records().stream() + .collect(Collectors.toMap(LearningCalendarResponseDto.DailyStudyRecord::date, record -> record)); + assertThat(records.get(LocalDate.of(2026, 6, 7)).moodEmojiKey()).isEqualTo("success_checkmark"); + } + + @Test + @DisplayName("학습 기록이 없는 날짜에는 감정 이모지를 저장할 수 없다") + void updateMood_recordNotFound() { + User targetUser = createUser("calendar-mood-empty-user"); + + assertThatThrownBy(() -> learningCalendarService.updateMood(targetUser.getId(), + new LearningCalendarMoodRequestDto(LocalDate.of(2026, 6, 7), "happy_tears"))) + .isInstanceOf(ApplicationException.class) + .hasMessageContaining(LearningCalendarErrorCase.CALENDAR_RECORD_NOT_FOUND.getMessage()); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java index d704bdfa..415be5f0 100644 --- a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java @@ -1,10 +1,12 @@ package com.aisip.OnO.backend.practicenote.service; import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.common.emoji.CustomEmojiErrorCase; import com.aisip.OnO.backend.folder.dto.FolderRegisterDto; import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.folder.repository.FolderRepository; import com.aisip.OnO.backend.practicenote.dto.PracticeNoteDetailResponseDto; +import com.aisip.OnO.backend.practicenote.dto.PracticeNoteCompleteRequestDto; import com.aisip.OnO.backend.practicenote.dto.PracticeNoteRegisterDto; import com.aisip.OnO.backend.practicenote.dto.PracticeNoteThumbnailResponseDto; import com.aisip.OnO.backend.practicenote.dto.PracticeNoteUpdateDto; @@ -282,6 +284,35 @@ void addPracticeNoteCount() { assertThat(practiceNote.getPracticeCount()).isEqualTo(1); } + @Test + @DisplayName("복습 완료 소감 이모지를 최신 복습노트에 저장한다") + void addPracticeNoteCount_withMoodEmojiKey() { + Long practiceNoteId = practiceNoteList.get(0).getId(); + + practiceNoteService.addPracticeNoteCount(userId, practiceNoteId, + new PracticeNoteCompleteRequestDto("success_checkmark")); + + PracticeNote practiceNote = practiceNoteRepository.findById(practiceNoteId).get(); + assertThat(practiceNote.getPracticeCount()).isEqualTo(1); + assertThat(practiceNote.getLastSessionMoodEmojiKey()).isEqualTo("success_checkmark"); + + PracticeNoteDetailResponseDto detail = practiceNoteService.findPracticeNoteDetail(practiceNoteId, userId); + assertThat(detail.lastSessionMoodEmojiKey()).isEqualTo("success_checkmark"); + assertThat(practiceNoteService.findAllPracticeThumbnailsByUser(userId).get(0).lastSessionMoodEmojiKey()) + .isEqualTo("success_checkmark"); + } + + @Test + @DisplayName("지원하지 않는 복습 완료 소감 이모지는 예외") + void addPracticeNoteCount_invalidMoodEmojiKey() { + Long practiceNoteId = practiceNoteList.get(0).getId(); + + assertThatThrownBy(() -> practiceNoteService.addPracticeNoteCount(userId, practiceNoteId, + new PracticeNoteCompleteRequestDto("not_supported"))) + .isInstanceOf(ApplicationException.class) + .hasMessageContaining(CustomEmojiErrorCase.INVALID_EMOJI_KEY.getMessage()); + } + @Test @DisplayName("복습 노트 수정 - 문제 리스트 수정") void updatePracticeInfo() { diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomApiIntegrationTest.java new file mode 100644 index 00000000..dec79b95 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomApiIntegrationTest.java @@ -0,0 +1,467 @@ +package com.aisip.OnO.backend.studyroom.integration; + +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeed; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedEventType; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; +import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class StudyRoomApiIntegrationTest { + + private static final String EMOJI = "fired_up_sparkle_eyes"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudyRoomRepository roomRepository; + + @Autowired + private StudyRoomFeedRepository feedRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private ProblemSolveRepository problemSolveRepository; + + @MockBean + private FileUploadService fileUploadService; + + @MockBean + private S3DeleteProducer s3DeleteProducer; + + private Long authenticatedUserId; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void roomCreateInviteJoinReadAndTodayPracticeSummaryApiFlow() throws Exception { + User host = saveUser("방장", "room-flow-host"); + User member = saveUser("멤버", "room-flow-member"); + authenticate(host.getId()); + + Long roomId = createRoom("수능 준비방"); + createPractice(host.getId(), LocalDateTime.now(), 2); + + String inviteCode = issueInviteCode(roomId); + authenticate(member.getId()); + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest(inviteCode)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomId").value(roomId)) + .andExpect(jsonPath("$.data.memberCount").value(2)); + createPractice(member.getId(), LocalDateTime.now(), 3); + + mockMvc.perform(get("/api/study-room/{roomId}", roomId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberCount").value(2)) + .andExpect(jsonPath("$.data.members[?(@.userId == " + host.getId() + ")].todayPracticeCount").value(2)) + .andExpect(jsonPath("$.data.members[?(@.userId == " + host.getId() + ")].practicedToday").value(true)) + .andExpect(jsonPath("$.data.members[?(@.userId == " + member.getId() + ")].todayPracticeCount").value(3)) + .andExpect(jsonPath("$.data.members[?(@.userId == " + member.getId() + ")].practicedToday").value(true)); + + mockMvc.perform(get("/api/study-room") + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.roomId == " + roomId + ")].todayPracticeMemberCount").value(2)) + .andExpect(jsonPath("$.data[?(@.roomId == " + roomId + ")].todayPracticeCount").value(5)); + } + + @Test + void hostCanUpdateRoomNameAndMemberCannotUpdateRoomName() throws Exception { + User host = saveUser("방장", "room-update-host"); + User member = saveUser("멤버", "room-update-member"); + authenticate(host.getId()); + Long roomId = createRoom("기존 이름"); + String inviteCode = issueInviteCode(roomId); + authenticate(member.getId()); + join(inviteCode); + + mockMvc.perform(patch("/api/study-room/{roomId}", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomUpdateRequest("멤버 수정")))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10003)); + + authenticate(host.getId()); + mockMvc.perform(patch("/api/study-room/{roomId}", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomUpdateRequest(" 새 스터디룸 ")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomId").value(roomId)) + .andExpect(jsonPath("$.data.name").value("새 스터디룸")) + .andExpect(jsonPath("$.data.memberCount").value(2)); + + mockMvc.perform(patch("/api/study-room/{roomId}", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomUpdateRequest(" ")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10016)); + } + + @Test + void hostCanUpdateThumbnailAndResponsesIncludeThumbnailUrl() throws Exception { + User host = saveUser("방장", "thumbnail-host"); + authenticate(host.getId()); + Long roomId = createRoom("썸네일방"); + MockMultipartFile thumbnail = new MockMultipartFile("thumbnail", "thumbnail.png", "image/png", pngBytes()); + given(fileUploadService.uploadFileToS3(any())).willReturn("https://cdn.example.com/study-room.png"); + + mockMvc.perform(multipart("/api/study-room/{roomId}/thumbnail", roomId) + .file(thumbnail) + .with(request -> { + request.setMethod("PATCH"); + return request; + }) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.thumbnailUrl").value("https://cdn.example.com/study-room.png")); + + mockMvc.perform(get("/api/study-room/{roomId}", roomId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.thumbnailUrl").value("https://cdn.example.com/study-room.png")); + + mockMvc.perform(get("/api/study-room") + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.roomId == " + roomId + ")].thumbnailUrl").value("https://cdn.example.com/study-room.png")); + } + + @Test + void hostCanUpdateRoomNameAndThumbnailInSingleMultipartRequest() throws Exception { + User host = saveUser("방장", "room-multipart-host"); + authenticate(host.getId()); + Long roomId = createRoom("기존 통합 수정방"); + MockMultipartFile thumbnailImage = new MockMultipartFile("thumbnailImage", "thumbnail.png", "image/png", pngBytes()); + given(fileUploadService.uploadFileToS3(any())).willReturn("https://cdn.example.com/updated-room.png"); + + mockMvc.perform(multipart("/api/study-rooms/{roomId}", roomId) + .file(thumbnailImage) + .param("name", "통합 수정방") + .with(request -> { + request.setMethod("PATCH"); + return request; + }) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomId").value(roomId)) + .andExpect(jsonPath("$.data.name").value("통합 수정방")) + .andExpect(jsonPath("$.data.thumbnailUrl").value("https://cdn.example.com/updated-room.png")); + } + + @Test + void thumbnailUploadRejectsInvalidImageFile() throws Exception { + User host = saveUser("방장", "thumbnail-invalid-host"); + authenticate(host.getId()); + Long roomId = createRoom("썸네일 검증방"); + MockMultipartFile thumbnail = new MockMultipartFile("thumbnail", "thumbnail.png", "image/png", "not-image".getBytes()); + + mockMvc.perform(multipart("/api/study-room/{roomId}/thumbnail", roomId) + .file(thumbnail) + .with(request -> { + request.setMethod("PATCH"); + return request; + }) + .with(auth())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(2003)); + } + + @Test + void nonMemberCannotReadRoomAndMemberCannotKickOtherMember() throws Exception { + User host = saveUser("방장", "permission-host"); + User member = saveUser("멤버", "permission-member"); + User outsider = saveUser("외부", "permission-outsider"); + authenticate(host.getId()); + Long roomId = createRoom("권한 검증방"); + String inviteCode = issueInviteCode(roomId); + authenticate(member.getId()); + join(inviteCode); + + authenticate(outsider.getId()); + mockMvc.perform(get("/api/study-room/{roomId}", roomId) + .with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10002)); + + authenticate(member.getId()); + mockMvc.perform(delete("/api/study-room/{roomId}/members/{memberId}", roomId, host.getId()) + .with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10003)); + } + + @Test + void feedReactionToggleAndInvalidEmojiAreValidatedByApi() throws Exception { + User host = saveUser("방장", "feed-host"); + authenticate(host.getId()); + Long roomId = createRoom("피드 반응방"); + StudyRoom room = roomRepository.findById(roomId).orElseThrow(); + StudyRoomFeed feed = feedRepository.save(StudyRoomFeed.create(room, host, StudyRoomFeedEventType.PROBLEM_REGISTERED, "{}")); + + mockMvc.perform(post("/api/study-room/{roomId}/feed/{feedId}/reactions", roomId, feed.getId()) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedId").value(feed.getId())) + .andExpect(jsonPath("$.data.reactions[0].emoji").value(EMOJI)) + .andExpect(jsonPath("$.data.reactions[0].count").value(1)) + .andExpect(jsonPath("$.data.reactions[0].reactedByMe").value(true)); + + mockMvc.perform(post("/api/study-room/{roomId}/feed/{feedId}/reactions", roomId, feed.getId()) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reactions").isEmpty()); + + mockMvc.perform(post("/api/study-room/{roomId}/feed/{feedId}/reactions", roomId, feed.getId()) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest("not_allowed")))) + .andExpect(status().isBadRequest()); + } + + @Test + void sharedProblemShareReactionAndDeleteAuthorizationApiFlow() throws Exception { + User host = saveUser("방장", "shared-host"); + User member = saveUser("멤버", "shared-member"); + authenticate(host.getId()); + Long roomId = createRoom("공유 문제방"); + String inviteCode = issueInviteCode(roomId); + Problem problem = createProblem(host.getId()); + authenticate(member.getId()); + join(inviteCode); + + authenticate(host.getId()); + Long sharedProblemId = shareProblem(roomId, problem.getId(), "같이 보면 좋은 문제"); + + authenticate(member.getId()); + mockMvc.perform(post("/api/study-room/{roomId}/shared-problems/{sharedProblemId}/reactions", roomId, sharedProblemId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sharedProblemId").value(sharedProblemId)) + .andExpect(jsonPath("$.data.reactions[0].count").value(1)) + .andExpect(jsonPath("$.data.reactions[0].reactedByMe").value(true)); + + mockMvc.perform(delete("/api/study-room/{roomId}/shared-problems/{sharedProblemId}", roomId, sharedProblemId) + .with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10002)); + + authenticate(host.getId()); + mockMvc.perform(delete("/api/study-room/{roomId}/shared-problems/{sharedProblemId}", roomId, sharedProblemId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value("공유 문제 삭제가 완료되었습니다.")); + } + + @Test + void challengeApiUsesCurrentPeriodPracticeCountsAndKeepsPeriodChallengeInProgress() throws Exception { + User host = saveUser("방장", "challenge-host"); + authenticate(host.getId()); + Long roomId = createRoom("챌린지방"); + LocalDateTime startAt = LocalDateTime.now().minusDays(10).truncatedTo(ChronoUnit.SECONDS); + LocalDateTime endAt = startAt.plusWeeks(3); + + mockMvc.perform(post("/api/study-room/{roomId}/challenges", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ChallengeCreateRequest( + "이번 주 복습 2회", + "individual", + "practice_count", + "weekly", + null, + 2, + startAt, + endAt + )))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.period").value("weekly")); + + createPractice(host.getId(), startAt.plusDays(1), 4); + createPractice(host.getId(), startAt.plusWeeks(1).plusHours(1), 2); + + mockMvc.perform(get("/api/study-room/{roomId}/challenges", roomId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].status").value("in_progress")) + .andExpect(jsonPath("$.data[0].memberProgress[0].current").value(2)) + .andExpect(jsonPath("$.data[0].memberProgress[0].cleared").value(true)); + + mockMvc.perform(post("/api/study-room/{roomId}/challenges", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ChallengeCreateRequest( + " ", + "individual", + "practice_count", + "weekly", + null, + 2, + startAt, + endAt + )))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10016)); + } + + private User saveUser(String name, String emailPrefix) { + return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + } + + private void authenticate(Long userId) { + authenticatedUserId = userId; + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, + null, + List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + ) + ); + } + + private RequestPostProcessor auth() { + return authentication(new UsernamePasswordAuthenticationToken( + authenticatedUserId, + null, + List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + )); + } + + private Long createRoom(String name) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomCreateRequest(name)))) + .andExpect(status().isCreated()) + .andReturn(); + return readLong(result, "$.data.roomId"); + } + + private String issueInviteCode(Long roomId) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room/{roomId}/invite", roomId) + .with(auth())) + .andExpect(status().isOk()) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.code"); + } + + private void join(String inviteCode) throws Exception { + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest(inviteCode)))) + .andExpect(status().isOk()); + } + + private Long shareProblem(Long roomId, Long problemId, String comment) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room/{roomId}/shared-problems", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCreateRequest(problemId, comment)))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.problemId").value(problemId)) + .andReturn(); + return readLong(result, "$.data.sharedProblemId"); + } + + private Long readLong(MvcResult result, String path) throws Exception { + Number value = JsonPath.read(result.getResponse().getContentAsString(), path); + return value.longValue(); + } + + private Problem createProblem(Long userId) { + return problemRepository.save(Problem.from( + new ProblemRegisterDto(null, "memo", "reference", null, LocalDateTime.now()), + userId + )); + } + + private void createPractice(Long userId, LocalDateTime practicedAt, int count) { + Problem problem = createProblem(userId); + for (int i = 0; i < count; i++) { + problemSolveRepository.save(ProblemSolve.create( + problem, + userId, + practicedAt.plusSeconds(i), + AnswerStatus.CORRECT, + null, + null, + 60 + )); + } + } + + private byte[] pngBytes() { + return new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, + 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D + }; + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomChallengeApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomChallengeApiIntegrationTest.java new file mode 100644 index 00000000..9dadf454 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomChallengeApiIntegrationTest.java @@ -0,0 +1,261 @@ +package com.aisip.OnO.backend.studyroom.integration; + +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.ChallengeCreateRequest; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.InviteCodeResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.StudyRoomCreateRequest; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.StudyRoomDetailResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.StudyRoomJoinRequest; +import com.aisip.OnO.backend.studyroom.service.StudyRoomInviteService; +import com.aisip.OnO.backend.studyroom.service.StudyRoomService; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class StudyRoomChallengeApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private StudyRoomService studyRoomService; + + @Autowired + private StudyRoomInviteService inviteService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private ProblemSolveRepository problemSolveRepository; + + private User host; + private User member; + private User outsider; + private Long roomId; + + @BeforeEach + void setUp() { + host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "challenge-host")); + member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "challenge-member")); + outsider = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "외부", "challenge-outsider")); + + authenticate(host.getId()); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("챌린지 테스트방"), host.getId()); + InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); + inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); + roomId = room.roomId(); + } + + @Test + void memberCanCreateWeeklyChallengeAndProgressCountsOnlyCurrentPeriod() throws Exception { + LocalDateTime startAt = LocalDateTime.now().minusDays(10).withNano(0); + LocalDateTime currentPeriodStart = startAt.plusWeeks(1); + createPractice(member.getId(), startAt.plusDays(2)); + createPractice(member.getId(), currentPeriodStart.plusDays(1)); + createPractice(member.getId(), currentPeriodStart.plusDays(2)); + ChallengeCreateRequest request = new ChallengeCreateRequest( + "이번 주 복습 2개", + "individual", + "practice_count", + "weekly", + null, + 2, + startAt, + startAt.plusWeeks(3) + ); + + authenticate(member.getId()); + MvcResult result = mockMvc.perform(post("/api/study-room/{roomId}/challenges", roomId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.title").value("이번 주 복습 2개")) + .andExpect(jsonPath("$.data.type").value("individual")) + .andExpect(jsonPath("$.data.metric").value("practice_count")) + .andExpect(jsonPath("$.data.period").value("weekly")) + .andExpect(jsonPath("$.data.status").value("in_progress")) + .andReturn(); + + List> memberProgress = JsonPath.read( + result.getResponse().getContentAsString(), + "$.data.memberProgress" + ); + Map targetMemberProgress = memberProgress.stream() + .filter(progress -> ((Number) progress.get("userId")).longValue() == member.getId()) + .findFirst() + .orElseThrow(); + assertThat(targetMemberProgress.get("current")).isEqualTo(2); + assertThat(targetMemberProgress.get("cleared")).isEqualTo(true); + } + + @Test + void nonMemberCannotGetChallenges() throws Exception { + authenticate(outsider.getId()); + + mockMvc.perform(get("/api/study-room/{roomId}/challenges", roomId)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10002)); + } + + @Test + void createChallengeRejectsInvalidPeriod() throws Exception { + ChallengeCreateRequest request = new ChallengeCreateRequest( + "잘못된 기간", + "individual", + "practice_count", + "yearly", + null, + 1, + null, + LocalDateTime.now().plusDays(1) + ); + + authenticate(member.getId()); + mockMvc.perform(post("/api/study-room/{roomId}/challenges", roomId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10016)); + } + + @Test + void hostCanDeleteChallengeAndMemberCannot() throws Exception { + ChallengeCreateRequest request = new ChallengeCreateRequest( + "삭제 테스트", "individual", "practice_count", null, + null, 3, null, LocalDateTime.now().plusDays(7) + ); + // 방장으로 챌린지 생성: 세션 간 인증 공유 문제를 피하기 위해 auth() RequestPostProcessor 사용 + MvcResult createResult = mockMvc.perform(post("/api/study-room/{roomId}/challenges", roomId) + .with(authentication(new UsernamePasswordAuthenticationToken( + host.getId(), null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER"))))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + Number challengeIdValue = JsonPath.read(createResult.getResponse().getContentAsString(), "$.data.challengeId"); + Long challengeId = challengeIdValue.longValue(); + + // 멤버는 삭제 불가 - HOST_ONLY (10003) + mockMvc.perform(delete("/api/study-room/{roomId}/challenges/{challengeId}", roomId, challengeId) + .with(authentication(new UsernamePasswordAuthenticationToken( + member.getId(), null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")))))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10003)); + + // 방장은 삭제 가능 + mockMvc.perform(delete("/api/study-room/{roomId}/challenges/{challengeId}", roomId, challengeId) + .with(authentication(new UsernamePasswordAuthenticationToken( + host.getId(), null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")))))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value("챌린지 삭제가 완료되었습니다.")); + } + + @Test + void createChallengeRejectsTitleTooLong() throws Exception { + ChallengeCreateRequest request = new ChallengeCreateRequest( + "a".repeat(41), "individual", "practice_count", null, + null, 3, null, LocalDateTime.now().plusDays(7) + ); + authenticate(member.getId()); + mockMvc.perform(post("/api/study-room/{roomId}/challenges", roomId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10016)); + } + + @Test + void createChallengeRejectsPastEndAt() throws Exception { + ChallengeCreateRequest request = new ChallengeCreateRequest( + "과거 종료일", "individual", "practice_count", null, + null, 3, null, LocalDateTime.now().minusDays(1) + ); + authenticate(member.getId()); + mockMvc.perform(post("/api/study-room/{roomId}/challenges", roomId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10016)); + } + + @Test + void createChallengeRejectsStartAtAfterEndAt() throws Exception { + ChallengeCreateRequest request = new ChallengeCreateRequest( + "날짜 역전", "individual", "practice_count", null, + null, 3, LocalDateTime.now().plusDays(5), LocalDateTime.now().plusDays(3) + ); + authenticate(member.getId()); + mockMvc.perform(post("/api/study-room/{roomId}/challenges", roomId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10016)); + } + + private void authenticate(Long userId) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, + null, + List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + ) + ); + } + + private void createPractice(Long userId, LocalDateTime practicedAt) { + Problem problem = problemRepository.save(Problem.from( + new ProblemRegisterDto(null, "memo", "reference", null, LocalDateTime.now()), + userId + )); + problemSolveRepository.save(ProblemSolve.create( + problem, + userId, + practicedAt, + AnswerStatus.CORRECT, + null, + null, + 60 + )); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomFeedApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomFeedApiTest.java new file mode 100644 index 00000000..007a80cd --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomFeedApiTest.java @@ -0,0 +1,221 @@ +package com.aisip.OnO.backend.studyroom.integration; + +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeed; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedEventType; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class StudyRoomFeedApiTest { + + private static final String EMOJI = "fired_up_sparkle_eyes"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudyRoomRepository roomRepository; + + @Autowired + private StudyRoomFeedRepository feedRepository; + + private Long authenticatedUserId; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void nonMemberCannotGetFeed() throws Exception { + User host = saveUser("방장", "feed-nonmember-host"); + User outsider = saveUser("외부인", "feed-nonmember-outsider"); + authenticate(host.getId()); + Long roomId = createRoom("피드 접근 테스트방"); + + // 멤버가 아닌 외부인이 피드 조회 → 403 (10002) + authenticate(outsider.getId()); + mockMvc.perform(get("/api/study-room/{roomId}/feed", roomId).with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10002)); + } + + @Test + void feedPaginationWithCursor() throws Exception { + User host = saveUser("방장", "feed-pagination-host"); + authenticate(host.getId()); + Long roomId = createRoom("피드 페이지네이션 테스트방"); + + // 피드 5개 직접 저장 (DB에 직접 저장 → 연속 ID 보장) + StudyRoom room = roomRepository.findById(roomId).orElseThrow(); + for (int i = 0; i < 5; i++) { + feedRepository.save(StudyRoomFeed.create(room, host, StudyRoomFeedEventType.PROBLEM_REGISTERED, "{}")); + } + + // 첫 번째 페이지: size=3 → 3개, hasNext=true + MvcResult firstResult = mockMvc.perform(get("/api/study-room/{roomId}/feed", roomId) + .param("size", "3") + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andReturn(); + + String firstBody = firstResult.getResponse().getContentAsString(); + List firstContent = JsonPath.read(firstBody, "$.data.content"); + assertThat(firstContent).hasSize(3); + Number nextCursor = JsonPath.read(firstBody, "$.data.nextCursor"); + + // 두 번째 페이지: cursor=nextCursor → 2개, hasNext=false + MvcResult secondResult = mockMvc.perform(get("/api/study-room/{roomId}/feed", roomId) + .param("size", "3") + .param("cursor", nextCursor.toString()) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andReturn(); + + String secondBody = secondResult.getResponse().getContentAsString(); + List secondContent = JsonPath.read(secondBody, "$.data.content"); + assertThat(secondContent).hasSize(2); + } + + @Test + void multipleUsersCanReactToSameFeed() throws Exception { + User host = saveUser("방장", "feed-react-host"); + User member = saveUser("멤버", "feed-react-member"); + authenticate(host.getId()); + Long roomId = createRoom("피드 반응 테스트방"); + String code = issueInviteCode(roomId); + authenticate(member.getId()); + join(code); + + // 피드 1개 직접 저장 + StudyRoom room = roomRepository.findById(roomId).orElseThrow(); + StudyRoomFeed feed = feedRepository.save( + StudyRoomFeed.create(room, host, StudyRoomFeedEventType.PROBLEM_REGISTERED, "{}")); + + // host가 반응 추가 → count=1 + authenticate(host.getId()); + mockMvc.perform(post("/api/study-room/{roomId}/feed/{feedId}/reactions", roomId, feed.getId()) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reactions[0].count").value(1)); + + // member가 동일 이모지로 반응 추가 → count=2, reactedByMe=true (member 기준) + authenticate(member.getId()); + mockMvc.perform(post("/api/study-room/{roomId}/feed/{feedId}/reactions", roomId, feed.getId()) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reactions[0].count").value(2)) + .andExpect(jsonPath("$.data.reactions[0].reactedByMe").value(true)); + } + + @Test + void nonMemberCannotToggleFeedReaction() throws Exception { + User host = saveUser("방장", "feed-react-perm-host"); + User outsider = saveUser("외부인", "feed-react-perm-outsider"); + authenticate(host.getId()); + Long roomId = createRoom("반응 권한 테스트방"); + + // 피드 1개 직접 저장 + StudyRoom room = roomRepository.findById(roomId).orElseThrow(); + StudyRoomFeed feed = feedRepository.save( + StudyRoomFeed.create(room, host, StudyRoomFeedEventType.PROBLEM_REGISTERED, "{}")); + + // 외부인이 반응 시도 → 403 (10002) + authenticate(outsider.getId()); + mockMvc.perform(post("/api/study-room/{roomId}/feed/{feedId}/reactions", roomId, feed.getId()) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10002)); + } + + // ========== 헬퍼 ========== + + private User saveUser(String name, String emailPrefix) { + return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + } + + private void authenticate(Long userId) { + authenticatedUserId = userId; + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + ) + ); + } + + private RequestPostProcessor auth() { + return authentication(new UsernamePasswordAuthenticationToken( + authenticatedUserId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + )); + } + + private Long createRoom(String name) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomCreateRequest(name)))) + .andExpect(status().isCreated()) + .andReturn(); + Number value = JsonPath.read(result.getResponse().getContentAsString(), "$.data.roomId"); + return value.longValue(); + } + + private String issueInviteCode(Long roomId) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room/{roomId}/invite", roomId).with(auth())) + .andExpect(status().isOk()) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.code"); + } + + private void join(String code) throws Exception { + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest(code)))) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomInviteApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomInviteApiTest.java new file mode 100644 index 00000000..cb0420e1 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomInviteApiTest.java @@ -0,0 +1,163 @@ +package com.aisip.OnO.backend.studyroom.integration; + +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class StudyRoomInviteApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + private Long authenticatedUserId; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void inviteCodeIsReusedWhenNotExpired() throws Exception { + User host = saveUser("방장", "invite-reuse-host"); + authenticate(host.getId()); + Long roomId = createRoom("코드 재사용 테스트방"); + + String code1 = issueInviteCode(roomId); + String code2 = issueInviteCode(roomId); + + // 아직 만료되지 않았으므로 동일 코드 반환 + assertThat(code2).isEqualTo(code1); + } + + @Test + void invalidFormatInviteCodeIsRejected() throws Exception { + User user = saveUser("사용자", "invite-invalid-user"); + authenticate(user.getId()); + + // 6자리 숫자가 아닌 코드 "abc" → 10006 + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest("abc")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10006)); + + // 7자리 숫자 코드 "1234567" → 10006 + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest("1234567")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10006)); + + // null 코드 → 10006 + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest(null)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10006)); + } + + @Test + void alreadyMemberCannotJoinRoom() throws Exception { + User host = saveUser("방장", "invite-already-member-host"); + authenticate(host.getId()); + Long roomId = createRoom("중복 참여 테스트방"); + String code = issueInviteCode(roomId); + + // host가 자신의 방에 초대코드로 재가입 시도 → ALREADY_MEMBER (10008) + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest(code)))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.errorCode").value(10008)); + } + + @Test + void nonExistentValidFormatCodeIsRejected() throws Exception { + User user = saveUser("사용자", "invite-nonexist-user"); + authenticate(user.getId()); + + // 형식은 맞지만 DB에 없는 코드 → INVITE_CODE_INVALID (10006) + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest("000000")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10006)); + } + + // ========== 헬퍼 ========== + + private User saveUser(String name, String emailPrefix) { + return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + } + + private void authenticate(Long userId) { + authenticatedUserId = userId; + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + ) + ); + } + + private RequestPostProcessor auth() { + return authentication(new UsernamePasswordAuthenticationToken( + authenticatedUserId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + )); + } + + private Long createRoom(String name) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomCreateRequest(name)))) + .andExpect(status().isCreated()) + .andReturn(); + Number value = JsonPath.read(result.getResponse().getContentAsString(), "$.data.roomId"); + return value.longValue(); + } + + private String issueInviteCode(Long roomId) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room/{roomId}/invite", roomId).with(auth())) + .andExpect(status().isOk()) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.code"); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomMemberManagementApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomMemberManagementApiTest.java new file mode 100644 index 00000000..5188241f --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomMemberManagementApiTest.java @@ -0,0 +1,265 @@ +package com.aisip.OnO.backend.studyroom.integration; + +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class StudyRoomMemberManagementApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + private Long authenticatedUserId; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // ========== 강퇴 권한 ========== + + @Test + void hostCanKickMemberAndMemberCannotKick() throws Exception { + User host = saveUser("방장", "mgmt-kick-host"); + User member = saveUser("멤버", "mgmt-kick-member"); + authenticate(host.getId()); + Long roomId = createRoom("강퇴 테스트방"); + String code = issueInviteCode(roomId); + authenticate(member.getId()); + join(code); + + // member가 host 강퇴 시도 → 방장만 가능 (10003) + mockMvc.perform(delete("/api/study-room/{roomId}/members/{memberId}", roomId, host.getId()) + .with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10003)); + + // host가 member 강퇴 → 성공 + authenticate(host.getId()); + mockMvc.perform(delete("/api/study-room/{roomId}/members/{memberId}", roomId, member.getId()) + .with(auth())) + .andExpect(status().isOk()); + + // 방 조회 → memberCount=1 + mockMvc.perform(get("/api/study-room/{roomId}", roomId).with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberCount").value(1)); + } + + @Test + void hostCannotKickSelf() throws Exception { + User host = saveUser("방장", "mgmt-selfkick-host"); + authenticate(host.getId()); + Long roomId = createRoom("자기 강퇴 테스트방"); + + // host가 자신을 강퇴 시도 → host 역할이라 막힘 (10003) + mockMvc.perform(delete("/api/study-room/{roomId}/members/{memberId}", roomId, host.getId()) + .with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10003)); + } + + // ========== 방 삭제 권한 ========== + + @Test + void hostCanDeleteRoomAndMemberCannot() throws Exception { + // 시나리오 A: 방에 속한 member가 삭제 시도 → 방장만 가능 (10003) + User host = saveUser("방장", "mgmt-delete-host"); + User member = saveUser("멤버", "mgmt-delete-member"); + authenticate(host.getId()); + Long roomIdWithMember = createRoom("삭제 권한 테스트방"); + String code = issueInviteCode(roomIdWithMember); + authenticate(member.getId()); + join(code); + + mockMvc.perform(delete("/api/study-room/{roomId}", roomIdWithMember).with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10003)); + + // 시나리오 B: host 단독 방(초대코드 없음) 삭제 → 성공 + // 초대코드 FK 제약이 없는 방에서 검증 + User host2 = saveUser("방장2", "mgmt-delete-host2"); + authenticate(host2.getId()); + Long roomIdAlone = createRoom("host 단독 삭제 테스트방"); + + mockMvc.perform(delete("/api/study-room/{roomId}", roomIdAlone).with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value("스터디룸 삭제가 완료되었습니다.")); + } + + // ========== 탈퇴 ========== + + @Test + void memberLeaveRemovesMemberFromRoom() throws Exception { + User host = saveUser("방장", "mgmt-leave-host"); + User member = saveUser("멤버", "mgmt-leave-member"); + authenticate(host.getId()); + Long roomId = createRoom("탈퇴 테스트방"); + String code = issueInviteCode(roomId); + authenticate(member.getId()); + join(code); + + // member 탈퇴 + mockMvc.perform(delete("/api/study-room/{roomId}/leave", roomId).with(auth())) + .andExpect(status().isOk()); + + // 탈퇴 후 접근 → 멤버가 아님 (10002) + mockMvc.perform(get("/api/study-room/{roomId}", roomId).with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10002)); + } + + @Test + void hostLeaveTransfersHostToNextMember() throws Exception { + User host = saveUser("방장", "mgmt-transfer-host"); + User member = saveUser("멤버", "mgmt-transfer-member"); + authenticate(host.getId()); + Long roomId = createRoom("위임 테스트방"); + String code = issueInviteCode(roomId); + authenticate(member.getId()); + join(code); + + // host 탈퇴 → member에게 방장 위임 + authenticate(host.getId()); + mockMvc.perform(delete("/api/study-room/{roomId}/leave", roomId).with(auth())) + .andExpect(status().isOk()); + + // member 기준으로 방 조회 → member가 방장, memberCount=1 + authenticate(member.getId()); + mockMvc.perform(get("/api/study-room/{roomId}", roomId).with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hostUserId").value(member.getId())) + .andExpect(jsonPath("$.data.memberCount").value(1)); + } + + @Test + void hostLeaveAloneDeletesRoom() throws Exception { + User host = saveUser("방장", "mgmt-alone-host"); + authenticate(host.getId()); + Long roomId = createRoom("혼자 탈퇴 테스트방"); + + // host 혼자 탈퇴 → 방 삭제 + mockMvc.perform(delete("/api/study-room/{roomId}/leave", roomId).with(auth())) + .andExpect(status().isOk()); + + // 방 조회 → 404 (10001) + mockMvc.perform(get("/api/study-room/{roomId}", roomId).with(auth())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value(10001)); + } + + // ========== 주간 목표 ========== + + @Test + void memberCanSetAndClearWeeklyGoalAndNegativeGoalIsRejected() throws Exception { + User host = saveUser("방장", "mgmt-goal-host"); + authenticate(host.getId()); + Long roomId = createRoom("목표 테스트방"); + + // weeklyGoal=20 설정 → 응답에 20 포함 + mockMvc.perform(put("/api/study-room/{roomId}/members/me/goal", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomGoalUpdateRequest(20)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.weeklyGoal").value(20)); + + // weeklyGoal=0 설정 → null로 초기화, HTTP 200 + mockMvc.perform(put("/api/study-room/{roomId}/members/me/goal", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomGoalUpdateRequest(0)))) + .andExpect(status().isOk()); + + // weeklyGoal=-1 → 유효성 오류 (10016) + mockMvc.perform(put("/api/study-room/{roomId}/members/me/goal", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomGoalUpdateRequest(-1)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10016)); + } + + // ========== 헬퍼 ========== + + private User saveUser(String name, String emailPrefix) { + return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + } + + private void authenticate(Long userId) { + authenticatedUserId = userId; + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + ) + ); + } + + private RequestPostProcessor auth() { + return authentication(new UsernamePasswordAuthenticationToken( + authenticatedUserId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + )); + } + + private Long createRoom(String name) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomCreateRequest(name)))) + .andExpect(status().isCreated()) + .andReturn(); + return readLong(result, "$.data.roomId"); + } + + private String issueInviteCode(Long roomId) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room/{roomId}/invite", roomId).with(auth())) + .andExpect(status().isOk()) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.code"); + } + + private void join(String code) throws Exception { + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest(code)))) + .andExpect(status().isOk()); + } + + private Long readLong(MvcResult result, String path) throws Exception { + Number value = JsonPath.read(result.getResponse().getContentAsString(), path); + return value.longValue(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemApiTest.java new file mode 100644 index 00000000..34e1dbbe --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemApiTest.java @@ -0,0 +1,354 @@ +package com.aisip.OnO.backend.studyroom.integration; + +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.dto.ProblemImageDataRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.entity.ProblemImageData; +import com.aisip.OnO.backend.problem.entity.ProblemImageType; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.practicenote.dto.PracticeNoteRegisterDto; +import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveRegisterDto; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class StudyRoomSharedProblemApiTest { + + private static final String EMOJI = "fired_up_sparkle_eyes"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private ProblemSolveRepository problemSolveRepository; + + private Long authenticatedUserId; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void nonMemberCannotGetSharedProblems() throws Exception { + User host = saveUser("방장", "shared-nonmember-host"); + User outsider = saveUser("외부인", "shared-nonmember-outsider"); + authenticate(host.getId()); + Long roomId = createRoom("공유 문제 접근 테스트방"); + + // 외부인이 공유 문제 목록 조회 → 403 (10002) + authenticate(outsider.getId()); + mockMvc.perform(get("/api/study-room/{roomId}/shared-problems", roomId).with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10002)); + } + + @Test + void commentTooLongIsRejected() throws Exception { + User host = saveUser("방장", "shared-comment-host"); + authenticate(host.getId()); + Long roomId = createRoom("댓글 길이 테스트방"); + Problem problem = createProblem(host.getId()); + + // 101자 댓글 → 유효성 오류 (10016) + String tooLongComment = "a".repeat(101); + mockMvc.perform(post("/api/study-room/{roomId}/shared-problems", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCreateRequest(problem.getId(), tooLongComment)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10016)); + } + + @Test + void nonSharerMemberCannotDeleteSharedProblem() throws Exception { + User host = saveUser("방장", "shared-delete-perm-host"); + User member = saveUser("멤버", "shared-delete-perm-member"); + authenticate(host.getId()); + Long roomId = createRoom("삭제 권한 테스트방"); + String code = issueInviteCode(roomId); + Problem problem = createProblem(host.getId()); + authenticate(member.getId()); + join(code); + + // host가 문제 공유 + authenticate(host.getId()); + Long sharedProblemId = shareProblem(roomId, problem.getId(), "공유 댓글"); + + // member가 host 공유 문제 삭제 시도 → 403 (10002) + authenticate(member.getId()); + mockMvc.perform(delete("/api/study-room/{roomId}/shared-problems/{sharedProblemId}", roomId, sharedProblemId) + .with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10002)); + } + + @Test + void sharerCanDeleteOwnSharedProblem() throws Exception { + User host = saveUser("방장", "shared-delete-own-host"); + authenticate(host.getId()); + Long roomId = createRoom("본인 삭제 테스트방"); + Problem problem = createProblem(host.getId()); + + // host가 공유 후 본인 삭제 + Long sharedProblemId = shareProblem(roomId, problem.getId(), "내 공유 문제"); + Long commentId = createComment(roomId, sharedProblemId, "삭제 전 댓글"); + mockMvc.perform(post("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments/{commentId}/reactions", + roomId, sharedProblemId, commentId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isOk()); + + mockMvc.perform(delete("/api/study-room/{roomId}/shared-problems/{sharedProblemId}", roomId, sharedProblemId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value("공유 문제 삭제가 완료되었습니다.")); + + // 삭제 후 목록 조회 → 빈 리스트 + MvcResult listResult = mockMvc.perform(get("/api/study-room/{roomId}/shared-problems", roomId).with(auth())) + .andExpect(status().isOk()) + .andReturn(); + List content = JsonPath.read(listResult.getResponse().getContentAsString(), "$.data.content"); + assertThat(content).isEmpty(); + } + + @Test + void sharedProblemPaginationWithCursor() throws Exception { + User host = saveUser("방장", "shared-pagination-host"); + authenticate(host.getId()); + Long roomId = createRoom("공유 문제 페이지네이션 테스트방"); + + // 5개 문제 공유 + for (int i = 0; i < 5; i++) { + Problem problem = createProblem(host.getId()); + shareProblem(roomId, problem.getId(), "댓글 " + i); + } + + // 첫 번째 페이지: size=3 → 3개, hasNext=true + MvcResult firstResult = mockMvc.perform(get("/api/study-room/{roomId}/shared-problems", roomId) + .param("size", "3") + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andReturn(); + + String firstBody = firstResult.getResponse().getContentAsString(); + List firstContent = JsonPath.read(firstBody, "$.data.content"); + assertThat(firstContent).hasSize(3); + Number nextCursor = JsonPath.read(firstBody, "$.data.nextCursor"); + + // 두 번째 페이지: cursor=nextCursor → 2개, hasNext=false + MvcResult secondResult = mockMvc.perform(get("/api/study-room/{roomId}/shared-problems", roomId) + .param("size", "3") + .param("cursor", nextCursor.toString()) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andReturn(); + + String secondBody = secondResult.getResponse().getContentAsString(); + List secondContent = JsonPath.read(secondBody, "$.data.content"); + assertThat(secondContent).hasSize(2); + } + + @Test + void sharedProblemListIncludesDetailFieldsForBoardSheet() throws Exception { + User host = saveUser("방장", "shared-detail-host"); + authenticate(host.getId()); + Long roomId = createRoom("공유 문제 상세 응답방"); + Problem problem = createProblem(host.getId(), "", "https://cdn.example.com/problem.png"); + Long sharedProblemId = shareProblem(roomId, problem.getId(), "풀이 의견을 남겨주세요"); + + createComment(roomId, sharedProblemId, "첫 번째 의견"); + createComment(roomId, sharedProblemId, "두 번째 의견"); + + mockMvc.perform(get("/api/study-room/{roomId}/shared-problems", roomId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].sharedProblemId").value(sharedProblemId)) + .andExpect(jsonPath("$.data.content[0].sharedByUserId").value(host.getId())) + .andExpect(jsonPath("$.data.content[0].sharedByName").value("방장")) + .andExpect(jsonPath("$.data.content[0].sharedByProfileImageUrl").isEmpty()) + .andExpect(jsonPath("$.data.content[0].problemId").value(problem.getId())) + .andExpect(jsonPath("$.data.content[0].problemImageUrl").value("https://cdn.example.com/problem.png")) + .andExpect(jsonPath("$.data.content[0].reference").value("공유 문제")) + .andExpect(jsonPath("$.data.content[0].comment").value("풀이 의견을 남겨주세요")) + .andExpect(jsonPath("$.data.content[0].commentCount").value(2)) + .andExpect(jsonPath("$.data.content[0].sharedAt").exists()) + .andExpect(jsonPath("$.data.content[0].reactions").isEmpty()); + } + + @Test + void sharedProblemDoesNotAllowOtherMemberToUseOriginalProblemAsOwnStudyData() throws Exception { + User host = saveUser("방장", "shared-readonly-host"); + User member = saveUser("멤버", "shared-readonly-member"); + authenticate(host.getId()); + Long roomId = createRoom("읽기 전용 정책방"); + String code = issueInviteCode(roomId); + Problem hostProblem = createProblem(host.getId()); + shareProblem(roomId, hostProblem.getId(), "읽기 전용 공유 문제"); + + authenticate(member.getId()); + join(code); + + mockMvc.perform(get("/api/problems/{problemId}", hostProblem.getId()) + .with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(4002)); + + mockMvc.perform(post("/api/problem-solves") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ProblemSolveRegisterDto( + hostProblem.getId(), + LocalDateTime.now(), + AnswerStatus.CORRECT, + "타인 공유 문제 복습 시도", + List.of(), + 60 + )))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(4002)); + + mockMvc.perform(post("/api/practiceNotes") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new PracticeNoteRegisterDto( + null, + "타인 공유 문제 편입 시도", + List.of(hostProblem.getId()), + null + )))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(4002)); + + assertThat(problemSolveRepository.countByProblemId(hostProblem.getId())).isZero(); + } + + // ========== 헬퍼 ========== + + private User saveUser(String name, String emailPrefix) { + return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + } + + private void authenticate(Long userId) { + authenticatedUserId = userId; + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + ) + ); + } + + private RequestPostProcessor auth() { + return authentication(new UsernamePasswordAuthenticationToken( + authenticatedUserId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + )); + } + + private Long createRoom(String name) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomCreateRequest(name)))) + .andExpect(status().isCreated()) + .andReturn(); + Number value = JsonPath.read(result.getResponse().getContentAsString(), "$.data.roomId"); + return value.longValue(); + } + + private String issueInviteCode(Long roomId) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room/{roomId}/invite", roomId).with(auth())) + .andExpect(status().isOk()) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.code"); + } + + private void join(String code) throws Exception { + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest(code)))) + .andExpect(status().isOk()); + } + + private Problem createProblem(Long userId) { + return createProblem(userId, "ref", null); + } + + private Problem createProblem(Long userId, String reference, String problemImageUrl) { + Problem problem = Problem.from( + new ProblemRegisterDto(null, "memo", reference, null, LocalDateTime.now()), + userId + ); + if (problemImageUrl != null) { + ProblemImageData imageData = ProblemImageData.from( + new ProblemImageDataRegisterDto(null, problemImageUrl, ProblemImageType.PROBLEM_IMAGE)); + imageData.updateProblem(problem); + } + return problemRepository.save(problem); + } + + private Long createComment(Long roomId, Long sharedProblemId, String content) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments", + roomId, sharedProblemId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCommentRequest(content)))) + .andExpect(status().isCreated()) + .andReturn(); + Number value = JsonPath.read(result.getResponse().getContentAsString(), "$.data.commentId"); + return value.longValue(); + } + + private Long shareProblem(Long roomId, Long problemId, String comment) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room/{roomId}/shared-problems", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCreateRequest(problemId, comment)))) + .andExpect(status().isCreated()) + .andReturn(); + Number value = JsonPath.read(result.getResponse().getContentAsString(), "$.data.sharedProblemId"); + return value.longValue(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemCommentApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemCommentApiTest.java new file mode 100644 index 00000000..7282be6c --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemCommentApiTest.java @@ -0,0 +1,338 @@ +package com.aisip.OnO.backend.studyroom.integration; + +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class StudyRoomSharedProblemCommentApiTest { + + private static final String EMOJI = "fired_up_sparkle_eyes"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProblemRepository problemRepository; + + private Long authenticatedUserId; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void memberCanCreateUpdateAndListSharedProblemComments() throws Exception { + User host = saveUser("방장", "comment-flow-host"); + User member = saveUser("멤버", "comment-flow-member"); + authenticate(host.getId()); + Long roomId = createRoom("댓글 테스트방"); + String inviteCode = issueInviteCode(roomId); + Long sharedProblemId = shareProblem(roomId, createProblem(host.getId()).getId(), "공유 문제"); + + authenticate(member.getId()); + join(inviteCode); + Long commentId = createComment(roomId, sharedProblemId, "처음 풀이 의견"); + + mockMvc.perform(patch("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments/{commentId}", + roomId, sharedProblemId, commentId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCommentRequest("수정된 풀이 의견")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentId").value(commentId)) + .andExpect(jsonPath("$.data.content").value("수정된 풀이 의견")) + .andExpect(jsonPath("$.data.authorId").value(member.getId())) + .andExpect(jsonPath("$.data.authorName").value("멤버")) + .andExpect(jsonPath("$.data.isEdited").value(true)) + .andExpect(jsonPath("$.data.isMine").value(true)) + .andExpect(jsonPath("$.data.canDelete").value(true)) + .andExpect(jsonPath("$.data.reactions").isEmpty()); + + MvcResult result = mockMvc.perform(get("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments", + roomId, sharedProblemId) + .param("size", "10") + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].commentId").value(commentId)) + .andExpect(jsonPath("$.data.content[0].content").value("수정된 풀이 의견")) + .andExpect(jsonPath("$.data.content[0].isMine").value(true)) + .andExpect(jsonPath("$.data.content[0].canDelete").value(true)) + .andExpect(jsonPath("$.data.content[0].reactions").isEmpty()) + .andReturn(); + + List comments = JsonPath.read(result.getResponse().getContentAsString(), "$.data.content"); + assertThat(comments).hasSize(1); + } + + @Test + void onlyAuthorCanUpdateAndHostCanDeleteOtherMemberComment() throws Exception { + User host = saveUser("방장", "comment-auth-host"); + User member = saveUser("멤버", "comment-auth-member"); + User anotherMember = saveUser("다른멤버", "comment-auth-another"); + authenticate(host.getId()); + Long roomId = createRoom("댓글 권한방"); + String inviteCode = issueInviteCode(roomId); + Long sharedProblemId = shareProblem(roomId, createProblem(host.getId()).getId(), "공유 문제"); + + authenticate(member.getId()); + join(inviteCode); + Long commentId = createComment(roomId, sharedProblemId, "멤버 댓글"); + + authenticate(anotherMember.getId()); + join(inviteCode); + mockMvc.perform(get("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments", + roomId, sharedProblemId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].isMine").value(false)) + .andExpect(jsonPath("$.data.content[0].canDelete").value(false)); + + mockMvc.perform(patch("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments/{commentId}", + roomId, sharedProblemId, commentId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCommentRequest("권한 없는 수정")))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10019)); + + mockMvc.perform(post("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments/{commentId}/reactions", + roomId, sharedProblemId, commentId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reactions[0].count").value(1)); + + authenticate(host.getId()); + mockMvc.perform(get("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments", + roomId, sharedProblemId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].isMine").value(false)) + .andExpect(jsonPath("$.data.content[0].canDelete").value(true)); + + mockMvc.perform(delete("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments/{commentId}", + roomId, sharedProblemId, commentId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value("공유 문제 댓글 삭제가 완료되었습니다.")); + + mockMvc.perform(get("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments", roomId, sharedProblemId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").isEmpty()); + } + + @Test + void commentPaginationUsesCursor() throws Exception { + User host = saveUser("방장", "comment-page-host"); + authenticate(host.getId()); + Long roomId = createRoom("댓글 페이지방"); + Long sharedProblemId = shareProblem(roomId, createProblem(host.getId()).getId(), "공유 문제"); + for (int i = 0; i < 5; i++) { + createComment(roomId, sharedProblemId, "댓글 " + i); + } + + MvcResult firstResult = mockMvc.perform(get("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments", + roomId, sharedProblemId) + .param("size", "3") + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andReturn(); + Number nextCursor = JsonPath.read(firstResult.getResponse().getContentAsString(), "$.data.nextCursor"); + + MvcResult secondResult = mockMvc.perform(get("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments", + roomId, sharedProblemId) + .param("size", "3") + .param("cursor", nextCursor.toString()) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andReturn(); + + List comments = JsonPath.read(secondResult.getResponse().getContentAsString(), "$.data.content"); + assertThat(comments).hasSize(2); + } + + @Test + void memberCanToggleSharedProblemCommentReaction() throws Exception { + User host = saveUser("방장", "comment-reaction-host"); + User member = saveUser("멤버", "comment-reaction-member"); + authenticate(host.getId()); + Long roomId = createRoom("댓글 반응방"); + String inviteCode = issueInviteCode(roomId); + Long sharedProblemId = shareProblem(roomId, createProblem(host.getId()).getId(), "공유 문제"); + Long commentId = createComment(roomId, sharedProblemId, "방장 댓글"); + + authenticate(member.getId()); + join(inviteCode); + + mockMvc.perform(post("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments/{commentId}/reactions", + roomId, sharedProblemId, commentId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentId").value(commentId)) + .andExpect(jsonPath("$.data.reactions[0].emoji").value(EMOJI)) + .andExpect(jsonPath("$.data.reactions[0].count").value(1)) + .andExpect(jsonPath("$.data.reactions[0].reactedByMe").value(true)); + + mockMvc.perform(get("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments", + roomId, sharedProblemId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].reactions[0].emoji").value(EMOJI)) + .andExpect(jsonPath("$.data.content[0].reactions[0].count").value(1)) + .andExpect(jsonPath("$.data.content[0].reactions[0].reactedByMe").value(true)); + + mockMvc.perform(post("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments/{commentId}/reactions", + roomId, sharedProblemId, commentId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reactions").isEmpty()); + } + + @Test + void blankOrTooLongCommentIsRejected() throws Exception { + User host = saveUser("방장", "comment-invalid-host"); + authenticate(host.getId()); + Long roomId = createRoom("댓글 검증방"); + Long sharedProblemId = shareProblem(roomId, createProblem(host.getId()).getId(), "공유 문제"); + + mockMvc.perform(post("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments", roomId, sharedProblemId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCommentRequest(" ")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10018)); + + mockMvc.perform(post("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments", roomId, sharedProblemId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCommentRequest("a".repeat(301))))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(10018)); + } + + private User saveUser(String name, String emailPrefix) { + return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + } + + private void authenticate(Long userId) { + authenticatedUserId = userId; + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, + null, + List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + ) + ); + } + + private RequestPostProcessor auth() { + return authentication(new UsernamePasswordAuthenticationToken( + authenticatedUserId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + )); + } + + private Long createRoom(String name) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomCreateRequest(name)))) + .andExpect(status().isCreated()) + .andReturn(); + return readLong(result, "$.data.roomId"); + } + + private String issueInviteCode(Long roomId) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room/{roomId}/invite", roomId) + .with(auth())) + .andExpect(status().isOk()) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.code"); + } + + private void join(String inviteCode) throws Exception { + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest(inviteCode)))) + .andExpect(status().isOk()); + } + + private Problem createProblem(Long userId) { + return problemRepository.save(Problem.from( + new ProblemRegisterDto(null, "memo", "reference", null, LocalDateTime.now()), + userId + )); + } + + private Long shareProblem(Long roomId, Long problemId, String comment) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room/{roomId}/shared-problems", roomId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCreateRequest(problemId, comment)))) + .andExpect(status().isCreated()) + .andReturn(); + return readLong(result, "$.data.sharedProblemId"); + } + + private Long createComment(Long roomId, Long sharedProblemId, String content) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-rooms/{roomId}/shared-problems/{sharedProblemId}/comments", + roomId, sharedProblemId) + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCommentRequest(content)))) + .andExpect(status().isCreated()) + .andReturn(); + return readLong(result, "$.data.commentId"); + } + + private Long readLong(MvcResult result, String path) throws Exception { + Number value = JsonPath.read(result.getResponse().getContentAsString(), path); + return value.longValue(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomWeeklyReportApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomWeeklyReportApiTest.java new file mode 100644 index 00000000..1ed35a43 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomWeeklyReportApiTest.java @@ -0,0 +1,218 @@ +package com.aisip.OnO.backend.studyroom.integration; + +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomWeeklyReport; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomWeeklyReportRepository; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.time.LocalDate; +import java.util.List; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class StudyRoomWeeklyReportApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudyRoomRepository roomRepository; + + @Autowired + private StudyRoomWeeklyReportRepository reportRepository; + + private Long authenticatedUserId; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void nonMemberCannotGetWeeklyReports() throws Exception { + User host = saveUser("방장", "report-nonmember-host"); + User outsider = saveUser("외부", "report-nonmember-outsider"); + authenticate(host.getId()); + Long roomId = createRoom("리포트 권한 테스트방"); + + authenticate(outsider.getId()); + mockMvc.perform(get("/api/study-room/{roomId}/weekly-reports", roomId) + .with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10002)); + } + + @Test + void memberGetsEmptyListWhenNoReportsExist() throws Exception { + User host = saveUser("방장", "report-empty-host"); + authenticate(host.getId()); + Long roomId = createRoom("빈 리포트방"); + + mockMvc.perform(get("/api/study-room/{roomId}/weekly-reports", roomId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(0)); + } + + @Test + void memberCanGetReportAndIsReadStatusIsCorrect() throws Exception { + User host = saveUser("방장", "report-read-host"); + User member = saveUser("멤버", "report-read-member"); + authenticate(host.getId()); + Long roomId = createRoom("리포트 읽음 테스트방"); + String inviteCode = issueInviteCode(roomId); + authenticate(member.getId()); + join(inviteCode); + + StudyRoom room = roomRepository.findById(roomId).orElseThrow(); + StudyRoomWeeklyReport report = reportRepository.save(StudyRoomWeeklyReport.create( + room, + LocalDate.now().minusDays(7), + LocalDate.now().minusDays(1), + "방장", 5, + "방장", 3, + 5, 0, + "이번 주도 수고하셨습니다!" + )); + + // 읽기 전: isRead=false + authenticate(member.getId()); + mockMvc.perform(get("/api/study-room/{roomId}/weekly-reports", roomId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].isRead").value(false)); + + // 읽음 처리: 응답에서 isRead=true + mockMvc.perform(patch("/api/study-room/{roomId}/weekly-reports/{reportId}/read", roomId, report.getId()) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isRead").value(true)); + + // 다시 조회: isRead=true + mockMvc.perform(get("/api/study-room/{roomId}/weekly-reports", roomId) + .with(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].isRead").value(true)); + } + + @Test + void markReadOnNonExistentReportThrowsNotFound() throws Exception { + User host = saveUser("방장", "report-notfound-host"); + User member = saveUser("멤버", "report-notfound-member"); + authenticate(host.getId()); + Long roomId = createRoom("리포트 없음 테스트방"); + String inviteCode = issueInviteCode(roomId); + authenticate(member.getId()); + join(inviteCode); + + mockMvc.perform(patch("/api/study-room/{roomId}/weekly-reports/{reportId}/read", roomId, 9999999L) + .with(auth())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value(10014)); + } + + @Test + void nonMemberCannotMarkReportRead() throws Exception { + User host = saveUser("방장", "report-forbid-host"); + User outsider = saveUser("외부", "report-forbid-outsider"); + authenticate(host.getId()); + Long roomId = createRoom("리포트 금지 테스트방"); + + StudyRoom room = roomRepository.findById(roomId).orElseThrow(); + StudyRoomWeeklyReport report = reportRepository.save(StudyRoomWeeklyReport.create( + room, + LocalDate.now().minusDays(7), + LocalDate.now().minusDays(1), + "방장", 5, + "방장", 3, + 5, 0, + "이번 주도 수고하셨습니다!" + )); + + authenticate(outsider.getId()); + mockMvc.perform(patch("/api/study-room/{roomId}/weekly-reports/{reportId}/read", roomId, report.getId()) + .with(auth())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10002)); + } + + // ========== 헬퍼 ========== + + private User saveUser(String name, String emailPrefix) { + return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + } + + private void authenticate(Long userId) { + authenticatedUserId = userId; + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(userId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER"))) + ); + } + + private RequestPostProcessor auth() { + return authentication(new UsernamePasswordAuthenticationToken( + authenticatedUserId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + )); + } + + private Long createRoom(String name) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomCreateRequest(name)))) + .andExpect(status().isCreated()) + .andReturn(); + Number value = JsonPath.read(result.getResponse().getContentAsString(), "$.data.roomId"); + return value.longValue(); + } + + private String issueInviteCode(Long roomId) throws Exception { + MvcResult result = mockMvc.perform(post("/api/study-room/{roomId}/invite", roomId) + .with(auth())) + .andExpect(status().isOk()) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.code"); + } + + private void join(String inviteCode) throws Exception { + mockMvc.perform(post("/api/study-room/join") + .with(auth()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new StudyRoomJoinRequest(inviteCode)))) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeServiceTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeServiceTest.java new file mode 100644 index 00000000..e80aabba --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeServiceTest.java @@ -0,0 +1,371 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.ChallengeResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomStats; +import com.aisip.OnO.backend.studyroom.entity.*; +import com.aisip.OnO.backend.studyroom.quartz.ChallengeNotificationScheduler; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomChallengeRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.util.ReflectionTestUtils.setField; + +@ExtendWith(MockitoExtension.class) +class StudyRoomChallengeServiceTest { + + @Mock + private StudyRoomAccessService accessService; + + @Mock + private StudyRoomChallengeRepository challengeRepository; + + @Mock + private StudyRoomMemberRepository memberRepository; + + @Mock + private StudyRoomStatsService statsService; + + @Mock + private ChallengeNotificationScheduler notificationScheduler; + + @Mock + private FcmService fcmService; + + private StudyRoomChallengeService challengeService; + + @BeforeEach + void setUp() { + challengeService = new StudyRoomChallengeService(accessService, challengeRepository, memberRepository, statsService, notificationScheduler, fcmService); + } + + @Test + void weeklyPeriodChallengeUsesCurrentPeriodRangeAndDoesNotCompleteEarly() { + LocalDateTime startAt = LocalDateTime.now().minusDays(10).truncatedTo(ChronoUnit.SECONDS); + LocalDateTime endAt = startAt.plusDays(21); + User user = user(1L, "멤버"); + StudyRoomMember member = StudyRoomMember.create(user, StudyRoomMemberRole.HOST); + StudyRoomChallenge challenge = challenge( + StudyRoomChallengeType.INDIVIDUAL, + StudyRoomChallengeMetric.PRACTICE_COUNT, + StudyRoomChallengePeriod.WEEKLY, + 3, + startAt, + endAt + ); + + given(memberRepository.findAllWithUserByRoomId(10L)).willReturn(List.of(member)); + given(challengeRepository.findAllByRoomIdOrderByEndAtAsc(10L)) + .willReturn(List.of(challenge)); + given(statsService.currentStreaks(anyList(), any(), any())).willReturn(Map.of(user.getId(), 0)); + given(statsService.getStats(anyList(), any(LocalDateTime.class), any(LocalDateTime.class), anyMap())) + .willReturn(Map.of(user.getId(), new StudyRoomStats(0, 3, 0))); + + List responses = challengeService.getChallenges(10L, user.getId()); + + assertThat(responses).hasSize(1); + assertThat(responses.get(0).status()).isEqualTo("in_progress"); + assertThat(responses.get(0).memberProgress()).singleElement().satisfies(progress -> { + assertThat(progress.current()).isEqualTo(3); + assertThat(progress.cleared()).isTrue(); + }); + ArgumentCaptor rangeStart = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor rangeEnd = ArgumentCaptor.forClass(LocalDateTime.class); + verify(statsService).getStats(anyList(), rangeStart.capture(), rangeEnd.capture(), anyMap()); + assertThat(rangeStart.getValue()).isEqualTo(startAt.plusWeeks(1)); + assertThat(rangeEnd.getValue()).isEqualTo(startAt.plusWeeks(2)); + } + + @Test + void nonPeriodGroupChallengeCompletesWhenGroupCurrentReachesTarget() { + LocalDateTime startAt = LocalDateTime.now().minusDays(1).truncatedTo(ChronoUnit.SECONDS); + LocalDateTime endAt = LocalDateTime.now().plusDays(1).truncatedTo(ChronoUnit.SECONDS); + User first = user(1L, "첫번째"); + User second = user(2L, "두번째"); + StudyRoomChallenge challenge = challenge( + StudyRoomChallengeType.GROUP, + StudyRoomChallengeMetric.PRACTICE_COUNT, + null, + 5, + startAt, + endAt + ); + + given(memberRepository.findAllWithUserByRoomId(10L)) + .willReturn(List.of(StudyRoomMember.create(first, StudyRoomMemberRole.HOST), + StudyRoomMember.create(second, StudyRoomMemberRole.MEMBER))); + given(challengeRepository.findAllByRoomIdOrderByEndAtAsc(10L)) + .willReturn(List.of(challenge)); + given(statsService.currentStreaks(anyList(), any(), any())).willReturn(Map.of(first.getId(), 0, second.getId(), 0)); + given(statsService.getStats(anyList(), any(LocalDateTime.class), any(LocalDateTime.class), anyMap())) + .willReturn(Map.of( + first.getId(), new StudyRoomStats(0, 2, 0), + second.getId(), new StudyRoomStats(0, 3, 0) + )); + + ChallengeResponse response = challengeService.getChallenges(10L, first.getId()).get(0); + + assertThat(response.status()).isEqualTo("completed"); + assertThat(response.groupCurrent()).isEqualTo(5); + } + + @Test + void dailyPeriodChallengeAggregatesCurrentDayWindow() { + // DAILY 주기: startAt = 3일 전 자정, endAt = 4일 후 자정 + LocalDateTime startAt = LocalDateTime.now().truncatedTo(ChronoUnit.DAYS).minusDays(3); + LocalDateTime endAt = startAt.plusDays(7); + User user = user(1L, "멤버"); + StudyRoomMember member = StudyRoomMember.create(user, StudyRoomMemberRole.HOST); + StudyRoomChallenge challenge = challenge( + StudyRoomChallengeType.INDIVIDUAL, + StudyRoomChallengeMetric.PRACTICE_COUNT, + StudyRoomChallengePeriod.DAILY, + 2, + startAt, + endAt + ); + + given(memberRepository.findAllWithUserByRoomId(10L)).willReturn(List.of(member)); + given(challengeRepository.findAllByRoomIdOrderByEndAtAsc(10L)) + .willReturn(List.of(challenge)); + given(statsService.currentStreaks(anyList(), any(), any())).willReturn(Map.of(user.getId(), 0)); + // practice_count=1, target=2 → cleared=false + given(statsService.getStats(anyList(), any(LocalDateTime.class), any(LocalDateTime.class), anyMap())) + .willReturn(Map.of(user.getId(), new StudyRoomStats(0, 1, 0))); + + List responses = challengeService.getChallenges(10L, user.getId()); + + assertThat(responses).hasSize(1); + ChallengeResponse response = responses.get(0); + assertThat(response.status()).isEqualTo("in_progress"); + assertThat(response.memberProgress()).singleElement().satisfies(p -> { + assertThat(p.current()).isEqualTo(1); + assertThat(p.cleared()).isFalse(); + }); + + // 오늘(day 3) 구간: [startAt+3days, startAt+4days] + ArgumentCaptor rangeStart = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor rangeEnd = ArgumentCaptor.forClass(LocalDateTime.class); + verify(statsService).getStats(anyList(), rangeStart.capture(), rangeEnd.capture(), anyMap()); + assertThat(rangeStart.getValue()).isEqualTo(startAt.plusDays(3)); + assertThat(rangeEnd.getValue()).isEqualTo(startAt.plusDays(4)); + } + + @Test + void individualChallengeStaysInProgressWhenNotAllMembersCleared() { + LocalDateTime startAt = LocalDateTime.now().minusDays(1).truncatedTo(ChronoUnit.SECONDS); + LocalDateTime endAt = LocalDateTime.now().plusDays(1).truncatedTo(ChronoUnit.SECONDS); + User member1 = user(1L, "첫번째"); + User member2 = user(2L, "두번째"); + StudyRoomChallenge challenge = challenge( + StudyRoomChallengeType.INDIVIDUAL, + StudyRoomChallengeMetric.PRACTICE_COUNT, + null, + 3, + startAt, + endAt + ); + + given(memberRepository.findAllWithUserByRoomId(10L)) + .willReturn(List.of( + StudyRoomMember.create(member1, StudyRoomMemberRole.HOST), + StudyRoomMember.create(member2, StudyRoomMemberRole.MEMBER) + )); + given(challengeRepository.findAllByRoomIdOrderByEndAtAsc(10L)) + .willReturn(List.of(challenge)); + given(statsService.currentStreaks(anyList(), any(), any())) + .willReturn(Map.of(member1.getId(), 0, member2.getId(), 0)); + // member1: practice_count=2 (미달), member2: practice_count=5 (달성) + given(statsService.getStats(anyList(), any(LocalDateTime.class), any(LocalDateTime.class), anyMap())) + .willReturn(Map.of( + member1.getId(), new StudyRoomStats(0, 2, 0), + member2.getId(), new StudyRoomStats(0, 5, 0) + )); + + ChallengeResponse response = challengeService.getChallenges(10L, member1.getId()).get(0); + + // 일부 멤버만 달성했으므로 in_progress 유지 + assertThat(response.status()).isEqualTo("in_progress"); + assertThat(response.memberProgress()).anySatisfy(p -> { + assertThat(p.userId()).isEqualTo(member1.getId()); + assertThat(p.cleared()).isFalse(); + }); + assertThat(response.memberProgress()).anySatisfy(p -> { + assertThat(p.userId()).isEqualTo(member2.getId()); + assertThat(p.cleared()).isTrue(); + }); + } + + @Test + void expiredEndAtChallengeReturnsExpiredStatus() { + LocalDateTime startAt = LocalDateTime.now().minusDays(10).truncatedTo(ChronoUnit.SECONDS); + LocalDateTime endAt = LocalDateTime.now().minusDays(2).truncatedTo(ChronoUnit.SECONDS); + User user = user(1L, "멤버"); + StudyRoomChallenge challenge = challenge( + StudyRoomChallengeType.INDIVIDUAL, + StudyRoomChallengeMetric.PRACTICE_COUNT, + null, + 10, + startAt, + endAt + ); + + given(memberRepository.findAllWithUserByRoomId(10L)) + .willReturn(List.of(StudyRoomMember.create(user, StudyRoomMemberRole.HOST))); + given(challengeRepository.findAllByRoomIdOrderByEndAtAsc(10L)) + .willReturn(List.of(challenge)); + given(statsService.currentStreaks(anyList(), any(), any())).willReturn(Map.of(user.getId(), 0)); + // practice_count=1, target=10 → 미달성이고 endAt도 과거 + given(statsService.getStats(anyList(), any(LocalDateTime.class), any(LocalDateTime.class), anyMap())) + .willReturn(Map.of(user.getId(), new StudyRoomStats(0, 1, 0))); + + ChallengeResponse response = challengeService.getChallenges(10L, user.getId()).get(0); + + assertThat(response.status()).isEqualTo("expired"); + } + + @Test + void attendanceMetricUsesAttendanceDayCountFromStatsService() { + LocalDateTime startAt = LocalDateTime.now().minusDays(1).truncatedTo(ChronoUnit.SECONDS); + LocalDateTime endAt = LocalDateTime.now().plusDays(1).truncatedTo(ChronoUnit.SECONDS); + User user = user(1L, "멤버"); + StudyRoomChallenge challenge = challenge( + StudyRoomChallengeType.INDIVIDUAL, + StudyRoomChallengeMetric.ATTENDANCE, + null, + 5, + startAt, + endAt + ); + + given(memberRepository.findAllWithUserByRoomId(10L)) + .willReturn(List.of(StudyRoomMember.create(user, StudyRoomMemberRole.HOST))); + given(challengeRepository.findAllByRoomIdOrderByEndAtAsc(10L)) + .willReturn(List.of(challenge)); + given(statsService.currentStreaks(anyList(), any(), any())).willReturn(Map.of(user.getId(), 0)); + given(statsService.getStats(anyList(), any(LocalDateTime.class), any(LocalDateTime.class), anyMap())) + .willReturn(Map.of(user.getId(), new StudyRoomStats(0, 0, 0))); + // attendanceDayCounts → 6 (target=5이므로 cleared=true) + given(statsService.attendanceDayCounts(anyList(), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(Map.of(user.getId(), 6)); + + List responses = challengeService.getChallenges(10L, user.getId()); + + assertThat(responses).hasSize(1); + assertThat(responses.get(0).memberProgress()).singleElement().satisfies(p -> { + assertThat(p.current()).isEqualTo(6); + assertThat(p.cleared()).isTrue(); + }); + verify(statsService).attendanceDayCounts(anyList(), any(LocalDateTime.class), any(LocalDateTime.class)); + } + + @Test + void monthlyPeriodChallengeAggregatesCurrentMonthWindow() { + // 2달 전 첫째날 자정부터 시작하는 MONTHLY 주기 챌린지 + LocalDateTime startAt = LocalDate.now().minusMonths(2).withDayOfMonth(1).atStartOfDay(); + LocalDateTime endAt = startAt.plusMonths(6); + User user = user(1L, "멤버"); + StudyRoomMember member = StudyRoomMember.create(user, StudyRoomMemberRole.HOST); + StudyRoomChallenge challenge = challenge( + StudyRoomChallengeType.INDIVIDUAL, + StudyRoomChallengeMetric.PRACTICE_COUNT, + StudyRoomChallengePeriod.MONTHLY, + 3, + startAt, + endAt + ); + + given(memberRepository.findAllWithUserByRoomId(10L)).willReturn(List.of(member)); + given(challengeRepository.findAllByRoomIdOrderByEndAtAsc(10L)) + .willReturn(List.of(challenge)); + given(statsService.currentStreaks(anyList(), any(), any())).willReturn(Map.of(user.getId(), 0)); + // practice_count=1, target=3 → 미달성 → status = in_progress + given(statsService.getStats(anyList(), any(LocalDateTime.class), any(LocalDateTime.class), anyMap())) + .willReturn(Map.of(user.getId(), new StudyRoomStats(0, 1, 0))); + + List responses = challengeService.getChallenges(10L, user.getId()); + + assertThat(responses).hasSize(1); + assertThat(responses.get(0).status()).isEqualTo("in_progress"); + assertThat(responses.get(0).memberProgress()).singleElement().satisfies(p -> { + assertThat(p.current()).isEqualTo(1); + assertThat(p.cleared()).isFalse(); + }); + + // 현재 구간 = [startAt.plusMonths(2), startAt.plusMonths(3)] 임을 검증 + ArgumentCaptor rangeStart = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor rangeEnd = ArgumentCaptor.forClass(LocalDateTime.class); + verify(statsService).getStats(anyList(), rangeStart.capture(), rangeEnd.capture(), anyMap()); + assertThat(rangeStart.getValue()).isEqualTo(startAt.plusMonths(2)); + assertThat(rangeEnd.getValue()).isEqualTo(startAt.plusMonths(3)); + } + + @Test + void streakMetricUsesAttendanceDayCount() { + LocalDateTime startAt = LocalDateTime.now().minusDays(14).truncatedTo(ChronoUnit.SECONDS); + LocalDateTime endAt = startAt.plusDays(28); + User user = user(7L, "멤버"); + StudyRoomMember member = StudyRoomMember.create(user, StudyRoomMemberRole.HOST); + StudyRoomChallenge challenge = challenge( + StudyRoomChallengeType.INDIVIDUAL, + StudyRoomChallengeMetric.STREAK, + null, + 5, + startAt, + endAt + ); + + given(memberRepository.findAllWithUserByRoomId(10L)).willReturn(List.of(member)); + given(challengeRepository.findAllByRoomIdOrderByEndAtAsc(10L)) + .willReturn(List.of(challenge)); + given(statsService.currentStreaks(anyList(), any(), any())).willReturn(Map.of(user.getId(), 7)); + given(statsService.getStats(anyList(), any(LocalDateTime.class), any(LocalDateTime.class), anyMap())) + .willReturn(Map.of(user.getId(), new StudyRoomStats(0, 0, 7))); + // attendanceDayCounts = 7, target = 5 → cleared=true + given(statsService.attendanceDayCounts(anyList(), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(Map.of(user.getId(), 7)); + + List responses = challengeService.getChallenges(10L, user.getId()); + + assertThat(responses).hasSize(1); + assertThat(responses.get(0).memberProgress()).singleElement().satisfies(p -> { + assertThat(p.current()).isEqualTo(7); + assertThat(p.cleared()).isTrue(); + }); + verify(statsService).attendanceDayCounts(anyList(), any(LocalDateTime.class), any(LocalDateTime.class)); + } + + private StudyRoomChallenge challenge(StudyRoomChallengeType type, StudyRoomChallengeMetric metric, + StudyRoomChallengePeriod period, int targetValue, + LocalDateTime startAt, LocalDateTime endAt) { + StudyRoom room = StudyRoom.create("테스트방", 1L); + setField(room, "id", 10L); + StudyRoomChallenge challenge = StudyRoomChallenge.create(room, "테스트 챌린지", type, metric, period, + null, targetValue, startAt, endAt); + setField(challenge, "id", 100L); + return challenge; + } + + private User user(Long id, String name) { + User user = RandomUserGenerator.createRandomUser("GOOGLE", name, "study-room-service-test"); + setField(user, "id", id); + return user; + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedServiceUnitTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedServiceUnitTest.java new file mode 100644 index 00000000..12685984 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedServiceUnitTest.java @@ -0,0 +1,128 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.common.emoji.CustomEmojiValidator; +import com.aisip.OnO.backend.studyroom.entity.*; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.util.ReflectionTestUtils.setField; + +@ExtendWith(MockitoExtension.class) +class StudyRoomFeedServiceUnitTest { + + @Mock + private StudyRoomAccessService accessService; + + @Mock + private StudyRoomMemberRepository memberRepository; + + @Mock + private StudyRoomFeedRepository feedRepository; + + @Mock + private StudyRoomFeedReactionRepository reactionRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private StudyRoomReactionService reactionService; + + @Mock + private CustomEmojiValidator customEmojiValidator; + + private StudyRoomFeedService feedService; + + private User user; + private StudyRoomMember membership; + private StudyRoom room; + + @BeforeEach + void setUp() { + user = RandomUserGenerator.createRandomUser("GOOGLE", "테스트", "feed-unit-test"); + setField(user, "id", 1L); + + room = StudyRoom.create("테스트방", 1L); + setField(room, "id", 10L); + + membership = StudyRoomMember.create(user, StudyRoomMemberRole.MEMBER); + membership.updateRoom(room); + + feedService = new StudyRoomFeedService( + accessService, memberRepository, feedRepository, + reactionRepository, userRepository, new ObjectMapper(), + reactionService, customEmojiValidator + ); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(memberRepository.findAllWithRoomByUserId(1L)).willReturn(List.of(membership)); + } + + @Test + void problemRegisteredAccumulatesCountOnSameDayFeed() { + // 오늘 이미 count=2인 피드가 존재 + StudyRoomFeed existingFeed = StudyRoomFeed.create( + room, user, StudyRoomFeedEventType.PROBLEM_REGISTERED, "{\"count\":2}"); + + given(feedRepository.findTopByRoomIdAndUserIdAndEventTypeAndCreatedAtBetweenOrderByCreatedAtDesc( + any(), any(), any(StudyRoomFeedEventType.class), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(Optional.of(existingFeed)); + + // count=3 추가 → 기존 count(2) + 신규 count(3) = 5 + feedService.createFeedsForUserRooms(1L, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", 3)); + + // 새 피드를 저장해서는 안 됨 + verify(feedRepository, never()).save(any()); + // 기존 피드의 count가 5로 업데이트되어야 함 + assertThat(existingFeed.getMetadataJson()).contains("\"count\":5"); + } + + @Test + void problemRegisteredCreatesNewFeedWhenNoSameDayFeedExists() { + // 오늘 동일 유형의 피드가 없음 + given(feedRepository.findTopByRoomIdAndUserIdAndEventTypeAndCreatedAtBetweenOrderByCreatedAtDesc( + any(), any(), any(StudyRoomFeedEventType.class), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(Optional.empty()); + given(feedRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + feedService.createFeedsForUserRooms(1L, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", 1)); + + // 새 피드가 저장되어야 함 + verify(feedRepository).save(any(StudyRoomFeed.class)); + } + + @Test + void nonProblemRegisteredEventAlwaysCreatesNewFeed() { + given(feedRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + // PROBLEM_REGISTERED 외 이벤트는 당일 중복 체크 없이 항상 새 피드 생성 + feedService.createFeedsForUserRooms(1L, StudyRoomFeedEventType.PRACTICE_COMPLETED, Map.of()); + + // 새 피드가 저장되어야 함 + verify(feedRepository).save(any(StudyRoomFeed.class)); + // 당일 기존 피드 조회 쿼리는 호출되면 안 됨 + verify(feedRepository, never()) + .findTopByRoomIdAndUserIdAndEventTypeAndCreatedAtBetweenOrderByCreatedAtDesc( + any(), any(), any(), any(), any()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomServiceIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomServiceIntegrationTest.java new file mode 100644 index 00000000..2091859e --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomServiceIntegrationTest.java @@ -0,0 +1,326 @@ +package com.aisip.OnO.backend.studyroom.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.InviteCodeResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.StudyRoomListResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.StudyRoomCreateRequest; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.StudyRoomDetailResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.StudyRoomGoalUpdateRequest; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.StudyRoomJoinRequest; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeed; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedEventType; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedReaction; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMemberRole; +import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; +import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@SpringBootTest +class StudyRoomServiceIntegrationTest { + + @Autowired + private StudyRoomService studyRoomService; + + @Autowired + private StudyRoomInviteService inviteService; + + @Autowired + private StudyRoomMemberRepository memberRepository; + + @Autowired + private StudyRoomRepository roomRepository; + + @Autowired + private StudyRoomFeedRepository feedRepository; + + @Autowired + private StudyRoomFeedReactionRepository feedReactionRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private ProblemSolveRepository problemSolveRepository; + + @MockBean + private FileUploadService fileUploadService; + + @MockBean + private S3DeleteProducer s3DeleteProducer; + + @Test + void createRoomCreatesHostMember() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + + StudyRoomDetailResponse response = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + + assertThat(response.name()).isEqualTo("수능 준비방"); + assertThat(response.hostUserId()).isEqualTo(host.getId()); + assertThat(response.memberCount()).isEqualTo(1); + assertThat(memberRepository.existsByRoomIdAndUserId(response.roomId(), host.getId())).isTrue(); + } + + @Test + void joinWithInviteCodeAddsMember() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); + + StudyRoomDetailResponse joined = inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); + + assertThat(joined.memberCount()).isEqualTo(2); + assertThat(memberRepository.existsByRoomIdAndUserId(room.roomId(), member.getId())).isTrue(); + } + + @Test + void memberCanRejoinAfterLeavingRoom() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); + inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); + + studyRoomService.leaveRoom(room.roomId(), member.getId()); + StudyRoomDetailResponse rejoined = inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); + + assertThat(rejoined.memberCount()).isEqualTo(2); + assertThat(memberRepository.existsByRoomIdAndUserId(room.roomId(), member.getId())).isTrue(); + } + + @Test + void feedReactionCanBeRecreatedAfterDelete() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + StudyRoomFeed feed = feedRepository.save(StudyRoomFeed.create( + roomRepository.findById(room.roomId()).orElseThrow(), + host, + StudyRoomFeedEventType.PROBLEM_REGISTERED, + "{}" + )); + StudyRoomFeedReaction reaction = feedReactionRepository.save(StudyRoomFeedReaction.create(feed, host, "fired_up_sparkle_eyes")); + + feedReactionRepository.delete(reaction); + feedReactionRepository.flush(); + StudyRoomFeedReaction recreated = feedReactionRepository.saveAndFlush(StudyRoomFeedReaction.create(feed, host, "fired_up_sparkle_eyes")); + + assertThat(recreated.getId()).isNotNull(); + assertThat(recreated.getEmoji()).isEqualTo("fired_up_sparkle_eyes"); + } + + @Test + void nonMemberCannotReadRoom() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User outsider = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "외부", "outsider")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + + assertThatThrownBy(() -> studyRoomService.getRoom(room.roomId(), outsider.getId())) + .isInstanceOf(ApplicationException.class) + .extracting("errorCase") + .isEqualTo(StudyRoomErrorCase.STUDY_ROOM_FORBIDDEN); + } + + @Test + void getMyRoomsIncludesTodayPracticeSummary() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); + inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); + createPractice(host.getId(), 2); + createPractice(member.getId(), 3); + + List rooms = studyRoomService.getMyRooms(host.getId()); + + StudyRoomListResponse response = rooms.stream() + .filter(item -> item.roomId().equals(room.roomId())) + .findFirst() + .orElseThrow(); + assertThat(response.memberCount()).isEqualTo(2); + assertThat(response.todayPracticeMemberCount()).isEqualTo(2); + assertThat(response.todayPracticeCount()).isEqualTo(5); + } + + @Test + void getRoomIncludesMemberTodayPracticeCount() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); + inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); + createPractice(host.getId(), 2); + createPractice(member.getId(), 0); + + StudyRoomDetailResponse response = studyRoomService.getRoom(room.roomId(), host.getId()); + + assertThat(response.members()).anySatisfy(item -> { + assertThat(item.userId()).isEqualTo(host.getId()); + assertThat(item.todayPracticeCount()).isEqualTo(2); + }); + assertThat(response.members()).anySatisfy(item -> { + assertThat(item.userId()).isEqualTo(member.getId()); + assertThat(item.todayPracticeCount()).isZero(); + }); + } + + @Test + void updateThumbnailUploadsAndStoresThumbnailUrl() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + MockMultipartFile firstThumbnail = new MockMultipartFile("thumbnail", "first.png", "image/png", pngBytes()); + MockMultipartFile secondThumbnail = new MockMultipartFile("thumbnail", "second.png", "image/png", pngBytes()); + given(fileUploadService.uploadFileToS3(firstThumbnail)).willReturn("https://cdn.example.com/first.png"); + given(fileUploadService.uploadFileToS3(secondThumbnail)).willReturn("https://cdn.example.com/second.png"); + + assertThat(studyRoomService.updateThumbnail(room.roomId(), host.getId(), firstThumbnail).thumbnailUrl()) + .isEqualTo("https://cdn.example.com/first.png"); + assertThat(studyRoomService.updateThumbnail(room.roomId(), host.getId(), secondThumbnail).thumbnailUrl()) + .isEqualTo("https://cdn.example.com/second.png"); + + assertThat(roomRepository.findById(room.roomId()).orElseThrow().getThumbnailUrl()) + .isEqualTo("https://cdn.example.com/second.png"); + verify(s3DeleteProducer).sendDeleteMessage("https://cdn.example.com/first.png", room.roomId()); + } + + @Test + void nonHostCannotUpdateThumbnail() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); + inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); + MockMultipartFile thumbnail = new MockMultipartFile("thumbnail", "first.png", "image/png", pngBytes()); + + assertThatThrownBy(() -> studyRoomService.updateThumbnail(room.roomId(), member.getId(), thumbnail)) + .isInstanceOf(ApplicationException.class) + .extracting("errorCase") + .isEqualTo(StudyRoomErrorCase.STUDY_ROOM_HOST_ONLY); + } + + @Test + void updateThumbnailRejectsInvalidMimeAndSignature() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + MockMultipartFile thumbnail = new MockMultipartFile("thumbnail", "first.png", "image/png", "not-image".getBytes()); + + assertThatThrownBy(() -> studyRoomService.updateThumbnail(room.roomId(), host.getId(), thumbnail)) + .isInstanceOf(ApplicationException.class) + .extracting("errorCase") + .isEqualTo(FileUploadErrorCase.INVALID_IMAGE_FILE); + verify(fileUploadService, never()).uploadFileToS3(thumbnail); + } + + @Test + void updateThumbnailRejectsOversizedImage() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + MockMultipartFile thumbnail = new MockMultipartFile("thumbnail", "large.png", "image/png", largePngBytes()); + + assertThatThrownBy(() -> studyRoomService.updateThumbnail(room.roomId(), host.getId(), thumbnail)) + .isInstanceOf(ApplicationException.class) + .extracting("errorCase") + .isEqualTo(FileUploadErrorCase.FILE_SIZE_EXCEEDED); + verify(fileUploadService, never()).uploadFileToS3(thumbnail); + } + + @Test + void hostLeaveTransfersHostToNextMember() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); + inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); + + studyRoomService.leaveRoom(room.roomId(), host.getId()); + + assertThat(memberRepository.existsByRoomIdAndUserId(room.roomId(), host.getId())).isFalse(); + assertThat(memberRepository.findByRoomIdAndUserId(room.roomId(), member.getId()).orElseThrow().getRole()) + .isEqualTo(StudyRoomMemberRole.HOST); + assertThat(roomRepository.findById(room.roomId()).orElseThrow().getHostUserId()).isEqualTo(member.getId()); + } + + @Test + void hostLeaveDeletesRoomWhenNoOtherMemberExists() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + + studyRoomService.leaveRoom(room.roomId(), host.getId()); + + assertThat(roomRepository.findById(room.roomId())).isEmpty(); + } + + @Test + void updateGoalCanSetAndClearWeeklyGoal() { + User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); + + assertThat(studyRoomService.updateGoal(room.roomId(), host.getId(), new StudyRoomGoalUpdateRequest(20)).weeklyGoal()) + .isEqualTo(20); + assertThat(studyRoomService.updateGoal(room.roomId(), host.getId(), new StudyRoomGoalUpdateRequest(0)).weeklyGoal()) + .isNull(); + } + + private void createPractice(Long userId, int count) { + Problem problem = problemRepository.save(Problem.from( + new ProblemRegisterDto(null, "memo", "reference", null, LocalDateTime.now()), + userId + )); + for (int i = 0; i < count; i++) { + problemSolveRepository.save(ProblemSolve.create( + problem, + userId, + LocalDateTime.now(), + AnswerStatus.CORRECT, + null, + null, + 60 + )); + } + } + + private byte[] pngBytes() { + return new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, + 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D + }; + } + + private byte[] largePngBytes() { + byte[] bytes = new byte[(5 * 1024 * 1024) + 1]; + byte[] header = pngBytes(); + System.arraycopy(header, 0, bytes, 0, header.length); + return bytes; + } +} diff --git a/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java b/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java index c062f266..4c540f31 100644 --- a/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java @@ -50,6 +50,7 @@ void setUp() { 1L, // userId "testUser", // name "test@example.com", // email + null, 1L, // level 0L, // point 1L, diff --git a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java index 373c1e45..96dae6a6 100644 --- a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java @@ -4,11 +4,15 @@ import com.aisip.OnO.backend.folder.service.FolderService; import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentReactionRepository; import com.aisip.OnO.backend.user.dto.UserRegisterDto; import com.aisip.OnO.backend.user.dto.UserResponseDto; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -18,6 +22,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; import java.util.List; import java.util.Optional; @@ -41,6 +46,14 @@ class UserServiceTest { @Mock private PracticeNoteService practiceNoteService; @Mock + private StudyRoomSharedProblemCommentRepository sharedProblemCommentRepository; + @Mock + private StudyRoomSharedProblemCommentReactionRepository sharedProblemCommentReactionRepository; + @Mock + private FileUploadService fileUploadService; + @Mock + private S3DeleteProducer s3DeleteProducer; + @Mock private DiscordWebhookNotificationService discordWebhookNotificationService; @InjectMocks @@ -122,8 +135,8 @@ void registerGuestUser() { assertThat(response.email()).contains("guest"); verify(userRepository, times(1)).save(any(User.class)); - verify(folderService, times(1)).initializeDefaultFoldersIfAbsent(any(Long.class)); - verify(practiceNoteService, times(1)).registerDefaultPractice(any(Long.class)); + verify(folderService, times(1)).initializeDefaultFoldersIfAbsent(any()); + verify(practiceNoteService, times(1)).registerDefaultPractice(any()); } @Test @@ -145,8 +158,8 @@ void registerMemberUser() { // save()가 반드시 한 번 호출되어야 함 verify(userRepository, times(1)).save(any(User.class)); - verify(folderService, times(1)).initializeDefaultFoldersIfAbsent(any(Long.class)); - verify(practiceNoteService, times(1)).registerDefaultPractice(any(Long.class)); + verify(folderService, times(1)).initializeDefaultFoldersIfAbsent(any()); + verify(practiceNoteService, times(1)).registerDefaultPractice(any()); } @Test @@ -247,6 +260,9 @@ void deleteUserById() { // Then assertThat(user.getIdentifier()).startsWith("deleted:" + userId + ":"); + verify(sharedProblemCommentReactionRepository, times(1)).deleteByCommentAuthorId(userId); + verify(sharedProblemCommentReactionRepository, times(1)).deleteByUserId(userId); + verify(sharedProblemCommentRepository, times(1)).deleteByAuthorId(userId); verify(practiceNoteService, times(1)).deleteAllPracticesByUser(userId); verify(problemService, times(1)).deleteAllUserProblems(userId); verify(folderService, times(1)).deleteAllUserFolders(userId); @@ -254,4 +270,51 @@ void deleteUserById() { verify(userRepository, times(1)).deleteById(userId); verify(userRepository, times(2)).flush(); } + + @Test + @DisplayName("프로필 이미지 업로드") + void updateProfileImage() { + // Given + Long userId = 1L; + User user = User.from(userRegisterDto); + MockMultipartFile profileImage = pngFile(); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(fileUploadService.uploadFileToS3(any())).thenReturn("https://cdn.example.com/profile.png"); + + // When + UserResponseDto response = userService.updateProfileImage(userId, profileImage); + + // Then + assertThat(response.profileImageUrl()).isEqualTo("https://cdn.example.com/profile.png"); + assertThat(user.getProfileImageUrl()).isEqualTo("https://cdn.example.com/profile.png"); + verify(fileUploadService, times(1)).uploadFileToS3(profileImage); + verify(s3DeleteProducer, never()).sendDeleteMessage(any(), any()); + } + + @Test + @DisplayName("프로필 이미지 삭제") + void deleteProfileImage() { + // Given + Long userId = 1L; + User user = User.from(userRegisterDto); + user.updateProfileImageUrl("https://cdn.example.com/old-profile.png"); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + // When + UserResponseDto response = userService.deleteProfileImage(userId); + + // Then + assertThat(response.profileImageUrl()).isNull(); + assertThat(user.getProfileImageUrl()).isNull(); + verify(s3DeleteProducer, times(1)).sendDeleteMessage("https://cdn.example.com/old-profile.png", userId); + } + + private MockMultipartFile pngFile() { + byte[] content = new byte[] { + (byte) 0x89, 0x50, 0x4E, 0x47, + 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x00 + }; + return new MockMultipartFile("profileImage", "profile.png", "image/png", content); + } }