diff --git a/.claude/hooks/ktlint-format.sh b/.claude/hooks/ktlint-format.sh new file mode 100755 index 00000000..c3b285f2 --- /dev/null +++ b/.claude/hooks/ktlint-format.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# PostToolUse hook: .kt 파일 수정 시 ktlint 자동 포맷 + +FILE_PATH=$(cat | jq -r '.tool_input.file_path // empty') + +if [ -z "$FILE_PATH" ]; then + exit 0 +fi + +# .kt 파일만 처리 +if [[ "$FILE_PATH" != *.kt ]]; then + exit 0 +fi + +cd "$CLAUDE_PROJECT_DIR" || exit 0 +./gradlew ktlintFormat 2>&1 >&2 diff --git a/.claude/rules/api-design.md b/.claude/rules/api-design.md index 6c08cbfe..bbefc763 100644 --- a/.claude/rules/api-design.md +++ b/.claude/rules/api-design.md @@ -17,6 +17,15 @@ class UserController( } ``` +## Club-scoped API + +Club resources use `/api/v4/clubs/{clubId}/...`. `clubId` is Base62 TSID — use two annotations together: + +```kotlin +@TsidParam // Swagger (type: string) +@TsidPathVariable clubId: Long // decodes Base62 → Long at runtime +``` + ## Required Annotations | Annotation | Purpose | @@ -61,9 +70,9 @@ enum class UserResponseCode( override val status: HttpStatus, override val message: String ) : ResponseCodeInterface { - GET_MY_INFO(1100, HttpStatus.OK, "내 정보 조회에 성공했습니다."), - GET_USER_INFO(1101, HttpStatus.OK, "다른 사용자 정보 조회에 성공했습니다."), - UPDATE_PROFILE_IMAGE(1102, HttpStatus.OK, "프로필 이미지 수정에 성공했습니다.") + USER_FIND_ALL_SUCCESS(10900, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), + USER_FIND_BY_ID_SUCCESS(10907, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), + USER_UPDATE_SUCCESS(10908, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), } ``` @@ -72,44 +81,70 @@ enum class UserResponseCode( - `CommonResponse.success(USER_FIND_BY_ID_SUCCESS, data)` - `CommonResponse.success(USER_UPDATE_SUCCESS)` -## Domain Success Codes +## Code Format + +| | Mean | Value | +|---|-----------------|----------------------------------------------------------------------------| +| X | Category | 1=Success, 2=Domain Error, 3=Infra/Server Error, 4=Client/Validation Error | +| DD | Domain ID | 01~99 | +| NN | In Domain Count | 00~99 | + +## Domain ID + +| DD | Domain | Success Range | Domain Error Range | Infra Error Range | +|----|------------|---------------|--------------------|-------------------| +| 01 | account | 10100~ | 20100~ | — | +| 02 | attendance | 10200~ | 20200~ | — | +| 03 | session | 10300~ | 20300~ | — | +| 04 | board | 10400~ | 20400~ | — | +| 05 | comment | 10500~ | 20500~ | — | +| 06 | file | 10600~ | 20600~ | 30600~ | +| 07 | penalty | 10700~ | 20700~ | — | +| 08 | schedule | 10800~ | 20800~ | — | +| 09 | user | 10900~ | 20900~ | — | +| 10 | cardinal | 11000~ | 21000~ | — | +| 11 | club | 11100~ | 21100~ | — | +| 12 | dashboard | 11200~ | 21200~ | — | +| 13 | university | 11300~ | — | 31300~ | +| 90 | jwt/auth | — | 29000~ | — | +| 99 | common | — | — | 39900~ | -Current project uses domain-specific success enums under `src/main/java/com/weeth/domain/*/presentation/*ResponseCode.java`. +## Domain Success Codes | Domain | ResponseCode Enum | Code Range | Location | |--------|------------------|------------|----------| -| Account | `AccountResponseCode` | `11xx` | `domain/account/presentation/` | -| Attendance | `AttendanceResponseCode` | `12xx` | `domain/attendance/presentation/` | -| Board | `BoardResponseCode` | `13xx` | `domain/board/presentation/` | -| Comment | `CommentResponseCode` | `140xx` | `domain/comment/presentation/` | -| File | `FileResponseCode` | `15xx` | `domain/file/presentation/` | -| Penalty | `PenaltyResponseCode` | `160xx` | `domain/penalty/presentation/` | -| Schedule | `ScheduleResponseCode` | `17xx` | `domain/schedule/presentation/` | -| User | `UserResponseCode` | `18xx` | `domain/user/presentation/` | +| Account | `AccountResponseCode` | `101xx` | `domain/account/presentation/` | +| Attendance | `AttendanceResponseCode` | `102xx` | `domain/attendance/presentation/` | +| Session | `SessionResponseCode` | `103xx` | `domain/session/presentation/` | +| Board | `BoardResponseCode` | `104xx` | `domain/board/presentation/` | +| Comment | `CommentResponseCode` | `105xx` | `domain/comment/presentation/` | +| File | `FileResponseCode` | `106xx` | `domain/file/presentation/` | +| Penalty | `PenaltyResponseCode` | `107xx` | `domain/penalty/presentation/` | +| Schedule | `ScheduleResponseCode` | `108xx` | `domain/schedule/presentation/` | +| User | `UserResponseCode` | `109xx` | `domain/user/presentation/` | +| Cardinal | `CardinalResponseCode` | `110xx` | `domain/cardinal/presentation/` | +| Club | `ClubResponseCode` | `111xx` | `domain/club/presentation/` | +| Dashboard | `DashboardResponseCode` | `112xx` | `domain/dashboard/presentation/` | +| University | `UniversityResponseCode` | `113xx` | `domain/university/presentation/` | ## Domain Error Codes -Domain-specific error enums under `src/main/java/com/weeth/domain/*/application/exception/*ErrorCode.java`. - | Domain | ErrorCode Enum | Code Range | Location | |--------|---------------|------------|----------| -| Account | `AccountErrorCode` | `21xx` | `domain/account/application/exception/` | -| Attendance | `AttendanceErrorCode` | `22xx` | `domain/attendance/application/exception/` | -| Board | `BoardErrorCode`, `NoticeErrorCode`, `PostErrorCode` | `23xx` | `domain/board/application/exception/` | -| Comment | `CommentErrorCode` | `240x` | `domain/comment/application/exception/` | -| Penalty | `PenaltyErrorCode` | `260x` | `domain/penalty/application/exception/` | -| Schedule | `EventErrorCode`, `MeetingErrorCode` | `27xx` | `domain/schedule/application/exception/` | -| User | `UserErrorCode` | `28xx` | `domain/user/application/exception/` | -| JWT (Global) | `JwtErrorCode` | `29xx` | `global/auth/jwt/exception/` | - -## Code Numbering - -| Range | Category | -|-------|----------| -| 1XXX | Success responses | -| 2XXX | Domain-specific errors | -| 3XXX | Server errors | -| 4XXX | Client errors | +| Account | `AccountErrorCode` | `201xx` | `domain/account/application/exception/` | +| Attendance | `AttendanceErrorCode` | `202xx` | `domain/attendance/application/exception/` | +| Session | `SessionErrorCode` | `203xx` | `domain/session/application/exception/` | +| Board | `BoardErrorCode` | `204xx` | `domain/board/application/exception/` | +| Comment | `CommentErrorCode` | `205xx` | `domain/comment/application/exception/` | +| File | `FileErrorCode` | `206xx` (domain), `306xx` (infra) | `domain/file/application/exception/` | +| Penalty | `PenaltyErrorCode` | `207xx` | `domain/penalty/application/exception/` | +| Schedule | `EventErrorCode` | `208xx` | `domain/schedule/application/exception/` | +| User | `UserErrorCode` | `209xx` | `domain/user/application/exception/` | +| Cardinal | `CardinalErrorCode` | `210xx` | `domain/cardinal/application/exception/` | +| Club | `ClubErrorCode` | `211xx` | `domain/club/application/exception/` | +| Dashboard | `DashboardErrorCode` | `212xx` | `domain/dashboard/application/exception/` | +| University | `UniversityErrorCode` | `313xx` (infra) | `domain/university/application/exception/` | +| JWT (Global) | `JwtErrorCode` | `290xx` | `global/auth/jwt/application/exception/` | ## HTTP Methods @@ -132,6 +167,17 @@ DELETE /users/{userId} # Delete user POST /users/{userId}/activate # Action on resource ``` +### Admin Endpoints + +`admin` prefix comes **before** `clubs/{clubId}`: `/api/v4/admin/clubs/{clubId}/{resource}` + +``` +/api/v4/clubs/{clubId}/boards # user-facing +/api/v4/admin/clubs/{clubId}/boards # admin +``` + +Enables a single SecurityConfig rule: `.requestMatchers("/api/v4/admin/**").hasRole("ADMIN")` + ## Query & Path Parameters - Query params for filtering: `?page=0&size=10&status=ACTIVE` diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index bd296c71..13700ee1 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -3,7 +3,7 @@ ## Package Structure ```text -src/main/kotlin/weeth/ +src/main/kotlin/com/weeth/ ├── domain/{domain-name}/ │ ├── application/ │ │ ├── dto/request/, dto/response/ @@ -26,7 +26,8 @@ src/main/kotlin/weeth/ └── global/ ├── auth/ ├── config/ - └── common/ + ├── common/ + └── logging/ ``` ## Layer Dependencies @@ -79,11 +80,50 @@ presentation → application → domain (owns Port) ## Entity (Rich Domain Model) -- **Factory method**: `companion object` with `create()` / `of()` including validation - **State changes**: named methods (`publish()`, `softDelete()`) — no public setters - **Validation**: `require` for argument checks, `check` for state preconditions - **Business decisions**: `isEditableBy()`, `canPublish()` belong to Entity +### Constructor Pattern + +Primary constructor takes **business creation params only** (non-property) — JPA-managed fields (`id`, `isDeleted`) belong in the body with `private set` and default values. + +```kotlin +@Entity +class Post( + title: String, + content: String, + user: User, + board: Board, +) : BaseEntity() { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + var title: String = title + private set + // ... + + companion object { + fun create(title: String, content: String, user: User, board: Board): Post { + require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다" } + return Post(title = title, content = content, user = user, board = board) + } + } +} +``` + +| Concern | Location | +|---------|----------| +| JPA-managed fields (`id`, `isDeleted`) | Body, `private set`, default value | +| Business creation params | Primary constructor (non-property) | +| Validation | `create()` / named mutation methods — not constructor | + +- **Factory method** (`companion object`): use when the entity has creation logic or validation. Expresses domain intent. +- **Simple entities** (e.g., `Board`): public constructor is fine; no factory method needed if creation is trivial. + ## Value Object (VO) - **Location**: `domain/vo/` @@ -154,8 +194,8 @@ class User( ## Port-Adapter Pattern -- **Port** (`domain/port/`): interface in domain language, no tech names → `FileStorage`, `PushNotificationSender` -- **Adapter** (`infrastructure/`): implementation with tech prefix → `S3FileStorage`, `FcmPushNotificationSender` +- **Port** (`domain/port/`): interface in domain language → `FileStoragePort`, `PushNotificationSenderPort` +- **Adapter** (`infrastructure/`): implementation with tech prefix → `S3FileStorageAdapter`, `FcmPushNotificationSenderAdapter` - UseCase depends on Port interface only → swappable, testable ## Core Principles @@ -164,4 +204,4 @@ class User( 2. **UseCase = orchestration**: coordinates flow; "how" is decided by Entity 3. **No meaningless services**: Repository wrappers are eliminated; Domain Service only for multi-entity logic 4. **Port-Adapter**: domain owns Port interfaces; infrastructure implements them -5. **Incremental migration**: migrate Java → Kotlin preserving existing structure +5. **Kotlin-first**: Java → Kotlin migration complete; all new code in Kotlin diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index d531ea3a..caf065dc 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -67,6 +67,17 @@ companion object { } ``` +## Comments + +- Do NOT comment on self-explanatory code +- Add comments in these cases: + - **Core business logic**: Domain rules, policy decisions — explain "why", not "what" + - **Collaboration aid**: Intent or background that other developers need to understand the code + - **Non-obvious implementation**: Performance optimizations, workarounds, external system constraints + - **Architecture decisions**: Reason for choosing a specific pattern or structure (e.g., `// NOTE: Kept in Java for Lombok @SuperBuilder compatibility`) +- Use KDoc (`/** */`) for public APIs, Port interfaces, and external contracts +- Use inline comments (`//`) for implementation intent within methods + ## Null Handling ```kotlin diff --git a/.claude/rules/exception-handling.md b/.claude/rules/exception-handling.md index 58bb93bd..5bee6001 100644 --- a/.claude/rules/exception-handling.md +++ b/.claude/rules/exception-handling.md @@ -6,7 +6,7 @@ RuntimeException └── BaseException (abstract) ├── UserNotFoundException - ├── OrderNotFoundException + ├── BoardNotFoundException └── ... (domain-specific exceptions) ``` @@ -40,17 +40,19 @@ enum class UserErrorCode( override val message: String ) : ErrorCodeInterface { @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") - USER_NOT_FOUND(2100, HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), - - @ExplainError("사용자 설정을 조회했으나 설정 정보가 존재하지 않을 때 발생합니다.") - USER_SETTING_NOT_FOUND(2101, HttpStatus.NOT_FOUND, "존재하지 않는 사용자 설정입니다."), - - @ExplainError("이미 탈퇴 처리된 사용자 계정에 접근을 시도할 때 발생합니다.") - USER_ALREADY_LEAVE(2102, HttpStatus.BAD_REQUEST, "이미 탈퇴한 사용자입니다."), + USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), + + @ExplainError("가입 승인 대기 중인 사용자가 접근을 시도할 때 발생합니다.") + USER_INACTIVE(20901, HttpStatus.FORBIDDEN, "가입 승인이 허가되지 않은 계정입니다."), + + @ExplainError("이미 가입된 이메일로 회원가입을 시도할 때 발생합니다.") + USER_EXISTS(20902, HttpStatus.BAD_REQUEST, "이미 가입된 사용자입니다."), } ``` -## Common Error Codes +## Common Error Codes (pattern example, not yet implemented) + +Follow the pattern below when introducing a common error code enum. Currently, `CommonExceptionHandler` uses `CommonResponse.createFailure()` directly. ```kotlin enum class CommonErrorCode( @@ -58,13 +60,13 @@ enum class CommonErrorCode( override val status: HttpStatus, override val message: String ) : ErrorCodeInterface { - // 3XXX: Server errors - INTERNAL_SERVER_ERROR(3001, HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), - JSON_PROCESSING_ERROR(3002, HttpStatus.INTERNAL_SERVER_ERROR, "JSON processing error"), + // 3DDNN: Infra/Server errors (DD=99 for common) + INTERNAL_SERVER_ERROR(39901, HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), + JSON_PROCESSING_ERROR(39902, HttpStatus.INTERNAL_SERVER_ERROR, "JSON processing error"), - // 4XXX: Client errors - INVALID_ARGUMENT(4001, HttpStatus.BAD_REQUEST, "Invalid argument"), - RESOURCE_NOT_FOUND(4003, HttpStatus.NOT_FOUND, "Resource not found"), + // 4DDNN: Client/Validation errors (DD=99 for common) + INVALID_ARGUMENT(49901, HttpStatus.BAD_REQUEST, "Invalid argument"), + RESOURCE_NOT_FOUND(49903, HttpStatus.NOT_FOUND, "Resource not found"), } ``` @@ -115,8 +117,8 @@ enum class UserErrorCode( override val status: HttpStatus, override val message: String ) : ErrorCodeInterface { - @ExplainError("Raised when no user exists for the given user ID.") - USER_NOT_FOUND(2100, HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), + @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") + USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), } ``` diff --git a/.claude/rules/mapper-dto.md b/.claude/rules/mapper-dto.md index 1ceae391..c1fd5289 100644 --- a/.claude/rules/mapper-dto.md +++ b/.claude/rules/mapper-dto.md @@ -2,18 +2,15 @@ ## Mapper Pattern -AS-IS (Java): MapStruct 사용 → TO-BE (Kotlin): 수동 Mapper 패턴으로 마이그레이션 +Manual `@Component` Mapper pattern (no MapStruct). ```kotlin @Component -class UserMapper( - private val profileMapper: ProfileMapper -) { +class UserMapper { fun toResponse(user: User) = UserResponse( id = user.id, name = user.name, email = user.email, - profile = profileMapper.toResponse(user.profile) ) fun toEntity(request: CreateUserRequest) = User( @@ -82,7 +79,9 @@ data class UserResponse( - Use non-nullable types for required fields - Use nullable types with default `null` for optional fields -## List Response with Pagination +## List Response with Pagination (pattern example) + +Follow the pattern below when introducing a pagination response DTO. ```kotlin data class UserListResponse( @@ -114,12 +113,11 @@ data class PageResponse( ## Mapper Dependencies -Mappers can inject other mappers: +Mappers can inject other mappers when needed: ```kotlin @Component -class UserMapper( - private val profileMapper: ProfileMapper, - private val addressMapper: AddressMapper +class PostMapper( + private val commentMapper: CommentMapper ) ``` diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 9585f2b6..e99df07e 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -20,12 +20,11 @@ ## Directory Structure ```text -src/test/kotlin/weeth/domain/{domain-name}/ +src/test/kotlin/com/weeth/domain/{domain-name}/ ├── application/usecase/command/ # Command UseCase tests ├── application/usecase/query/ # QueryService tests ├── domain/service/ # Domain service tests (multi-entity logic) ├── domain/entity/ # Entity behavior tests -├── presentation/ # Controller tests (@WebMvcTest) └── fixture/ # Shared fixtures for the domain ``` @@ -74,7 +73,7 @@ object UserTestFixture { } ``` -- Location: `src/test/kotlin/weeth/domain/{domain-name}/fixture/` +- Location: `src/test/kotlin/com/weeth/domain/{domain-name}/fixture/` - Use `object` with factory methods - Provide sensible defaults for all parameters - Reuse across test classes in the same domain @@ -93,6 +92,30 @@ object UserTestFixture { - Getter/setter, trivial DTO mapping - Framework-provided functionality +## Mock Lifecycle in DescribeSpec + +MockK mocks are **not** automatically cleared between `it` blocks. Without clearing, accumulated invocations cause `verify(exactly = N)` to fail in subsequent tests. + +Always add `beforeTest { clearMocks(...) }` when mocks are shared: + +```kotlin +class SomeUseCaseTest : DescribeSpec({ + val repository = mockk() + val useCase = SomeUseCase(repository) + + beforeTest { + clearMocks(repository) + // Re-stub defaults after clearing + every { repository.save(any()) } answers { firstArg() } + } + + describe("someMethod") { + it("case 1") { verify(exactly = 1) { repository.save(any()) } } + it("case 2") { verify(exactly = 1) { repository.save(any()) } } // OK - count reset + } +}) +``` + ## Running Tests ```bash diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..89d0d3af --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,58 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ktlint-format.sh", + "timeout": 120, + "statusMessage": "Running ktlint format..." + } + ] + } + ] + }, + "permissions": { + "deny": [ + "Edit(**/.env*)", + "Edit(**/*.pem)", + "Edit(**/*.key)", + "Edit(**/*secret*)", + "Edit(**/*credential*)", + "Write(**/.env*)", + "Write(**/*.pem)", + "Write(**/*.key)", + "Write(**/*secret*)", + "Write(**/*credential*)" + ], + "allow": [ + "Read", + "Glob", + "Grep", + "NotebookEdit", + "mcp__ide__getDiagnostics", + "Edit(src/main/**)", + "Edit(src/test/**)", + "Edit(docs/**)", + "Edit(build.gradle.kts)", + "Edit(settings.gradle.kts)", + "Edit(CLAUDE.md)", + "Edit(.claude/**)", + "Edit(.editorconfig)", + "Write(src/main/**)", + "Write(src/test/**)", + "Write(docs/**)", + "Write(.claude/**)" + ], + "ask": [ + "Bash(git *)", + "Bash(gh *)", + "Edit(src/main/resources/application-prod*)", + "Edit(src/main/resources/application-dev*)", + "Write(src/main/resources/application-prod*)", + "Write(src/main/resources/application-dev*)" + ] + } +} diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 134a4eb0..5e5d9739 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -36,16 +36,16 @@ For each issue provide: ## Review Checklist ### Bug/Logic -- Null safety (no `!!`, use nullable types) +- Null safety (avoid "!!", use nullable types) - Edge case handling -- Exception handling (must extend `BaseException`) +- Exception handling (must extend BaseException) - Concurrency issues (race conditions) ### Security - SQL Injection (raw queries, string concatenation) - Sensitive data exposure (logs, responses) -- Missing auth (`@CurrentUser` usage) -- Input validation (`@Valid`, `@NotNull`, `@NotBlank`) +- Missing auth (@CurrentUser usage) +- Input validation (@Valid, @NotNull, @NotBlank) ### Performance - N+1 query (repository calls inside loops) @@ -56,17 +56,17 @@ For each issue provide: - Layer adherence: Controller → UseCase → Repository (UseCase uses Repository directly) - Rich Domain Model: business logic in Entity, not UseCase - No thin wrapper services (GetService/SaveService) — Domain Service only for multi-entity logic -- `@Transactional` only on UseCase methods +- @Transactional only on UseCase methods - Port-Adapter: UseCase depends on Port interface, not infrastructure directly - Cross-domain read via Reader interface, cross-domain write via Repository directly - No layer skipping (Controller → Repository is forbidden) ### Kotlin-specific -- `val` over `var` +- val over var - Nullable type overuse -- Scope function opportunities (`let`, `apply`, `also`) +- Scope function opportunities (let, apply, also) - data class for DTOs -- `when` expression over if-else chains +- when expression over if-else chains ## Output Format diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5efde322 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.idea +.gradle +.claude +out +docs +infra + +**/Dockerfile* +**/*.iml +**/.DS_Store diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..be2dacec --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*.{kt,kts}] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 120 + +# wildcard import 방지 +ktlint_standard_no-wildcard-imports = enabled +ij_kotlin_name_count_to_use_star_import = 999 +ij_kotlin_name_count_to_use_star_import_for_members = 999 + +# trailing comma 설정 +ktlint_standard_trailing-comma-on-call-site = enabled +ktlint_standard_trailing-comma-on-declaration-site = enabled diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0ce50809..28f267fd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,20 +1,27 @@ ## 📌 Summary > 어떤 작업인지 한 줄 요약해 주세요. + ## 📝 Changes > 변경사항을 what, why, how로 구분해 작성해 주세요. + ### What + ### Why + ### How + ## 📸 Screenshots / Logs > 필요시 스크린샷 or 로그를 첨부해주세요. + ## 💡 Reviewer 참고사항 > 리뷰에 참고할 내용을 작성해주세요. + ## ✅ Checklist - [ ] PR 제목 설정 완료 ([WTH-123] 인증 필터 설정) - [ ] 테스트 구현 완료 diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 00000000..2235b905 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,55 @@ +name-template: "v$RESOLVED_VERSION" +tag-template: "v$RESOLVED_VERSION" + +categories: + - title: "✨ Features" + labels: ["✨ Feature"] + - title: "🐞 Bug Fixes" + labels: ["🐞 BugFix"] + - title: "🔐 Security" + labels: ["🔐 Security"] + - title: "🔨 Refactors" + labels: ["🔨 Refactor"] + - title: "✅ Tests" + labels: ["✅ Test"] + - title: "📃 Docs" + labels: ["📃 Docs"] + - title: "🌏 Deploy" + labels: ["🌏 Deploy"] + - title: "⚙ Settings" + labels: ["⚙ Setting"] + - title: "💻 Cross Browsing" + labels: ["💻 CrossBrowsing"] + - title: "📬 API" + labels: ["📬 API"] + - title: "🤖 Agent" + labels: ["🤖 Agent"] + +change-template: "- $TITLE (#$NUMBER) @$AUTHOR" +change-title-escapes: "<*_&" + +version-resolver: + major: + labels: ["💥 Breaking"] + + minor: + labels: ["✨ Feature"] + + patch: + labels: + - "🐞 BugFix" + - "🔐 Security" + - "🔨 Refactor" + - "✅ Test" + - "📃 Docs" + - "🌏 Deploy" + - "⚙ Setting" + - "💻 CrossBrowsing" + - "📬 API" + - "🤖 Agent" + + default: patch + +template: | + ## Changes + $CHANGES diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e1d4298c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + pull_request: + branches: [dev, main] + push: + branches: [dev, main] + +jobs: + ci: + runs-on: ubuntu-latest + services: + redis: + image: redis:7.2 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Check ktlint + run: ./gradlew ktlintCheck --no-daemon + + - name: Build and test + run: ./gradlew clean test --no-daemon diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 00000000..9fd57bee --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,101 @@ +name: Deploy Dev + +on: + workflow_run: + workflows: [ "CI" ] + branches: [ dev ] + types: [ completed ] + +permissions: + contents: read + +env: + IMAGE_REPOSITORY: ${{ secrets.DOCKERHUB_REPOSITORY }} + +jobs: + build: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'dev' + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build JAR + run: ./gradlew clean build -x test --no-daemon + + # ARM Docker Build 설정 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push dev image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/arm64 # t4g small 배포를 위한 arm platform 설정 + push: true + tags: | + ${{ env.IMAGE_REPOSITORY }}:dev-latest + ${{ env.IMAGE_REPOSITORY }}:dev-${{ github.event.workflow_run.head_sha }} + + deploy: + needs: build + runs-on: ubuntu-latest + if: needs.build.result == 'success' + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + # /infra/dev 하위 파일 EC2로 복사 + - name: Upload deployment files to EC2 + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + source: "infra/dev" + target: "${{ secrets.DEV_DEPLOY_DIR }}" + + - name: Deploy to dev server + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR="${{ secrets.DEV_DEPLOY_DIR }}/infra/dev" + chmod +x "$DEPLOY_DIR/scripts/deploy.sh" + + APP_IMAGE="${{ env.IMAGE_REPOSITORY }}:dev-${{ github.event.workflow_run.head_sha }}" \ + DOMAIN="${{ secrets.DEV_DOMAIN }}" \ + DEPLOY_DIR="$DEPLOY_DIR" \ + "$DEPLOY_DIR/scripts/deploy.sh" diff --git a/.github/workflows/deploy-monitoring-dev.yml b/.github/workflows/deploy-monitoring-dev.yml new file mode 100644 index 00000000..78510d0c --- /dev/null +++ b/.github/workflows/deploy-monitoring-dev.yml @@ -0,0 +1,40 @@ +name: Deploy Monitoring (Dev) + +on: + push: + branches: [dev] + paths: + - "infra/dev/monitoring/**" + - ".github/workflows/deploy-monitoring-dev.yml" + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Upload monitoring files to EC2 + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + source: "infra/dev/monitoring" + target: "${{ secrets.DEV_DEPLOY_DIR }}" + + - name: Deploy monitoring stack + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR="${{ secrets.DEV_DEPLOY_DIR }}/infra/dev/monitoring" + chmod +x "$DEPLOY_DIR/scripts/deploy.sh" + DEPLOY_DIR="$DEPLOY_DIR" "$DEPLOY_DIR/scripts/deploy.sh" diff --git a/.github/workflows/deploy-monitoring-prod.yml b/.github/workflows/deploy-monitoring-prod.yml new file mode 100644 index 00000000..f5997c4f --- /dev/null +++ b/.github/workflows/deploy-monitoring-prod.yml @@ -0,0 +1,42 @@ +name: Deploy Monitoring (Prod) + +# 운영 서버가 설정되지 않았기 때문에 수동 실행 설정 +on: +# push: +# branches: [ main ] +# paths: +# - "infra/prod/monitoring/**" +# - ".github/workflows/deploy-monitoring-prod.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Upload monitoring files to EC2 + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.PROD_EC2_HOST }} + username: ${{ secrets.PROD_EC2_USER }} + key: ${{ secrets.PROD_EC2_SSH_KEY }} + source: "infra/prod/monitoring" + target: "${{ secrets.PROD_DEPLOY_DIR }}" + + - name: Deploy monitoring stack + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.PROD_EC2_HOST }} + username: ${{ secrets.PROD_EC2_USER }} + key: ${{ secrets.PROD_EC2_SSH_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR="${{ secrets.PROD_DEPLOY_DIR }}/infra/prod/monitoring" + chmod +x "$DEPLOY_DIR/scripts/deploy.sh" + DEPLOY_DIR="$DEPLOY_DIR" "$DEPLOY_DIR/scripts/deploy.sh" diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 00000000..a89d0ac5 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,91 @@ +name: Deploy Prod + +on: + release: + types: [published] + +permissions: + contents: read + +env: + IMAGE_REPOSITORY: ${{ secrets.DOCKERHUB_REPOSITORY }} + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build JAR + run: ./gradlew clean build -x test --no-daemon + + # ARM Docker Build 설정 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push prod image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/arm64 # t4g small 배포를 위한 arm platform 설정 + push: true + tags: | + ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name }} + ${{ env.IMAGE_REPOSITORY }}:prod-latest + + deploy: + needs: build + runs-on: ubuntu-latest + if: needs.build.result == 'success' + + steps: + - name: Checkout + uses: actions/checkout@v6 + + # /infra/prod 하위 파일 EC2로 복사 + - name: Upload deployment files to EC2 + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.PROD_EC2_HOST }} + username: ${{ secrets.PROD_EC2_USER }} + key: ${{ secrets.PROD_EC2_SSH_KEY }} + source: "infra/prod" + target: "${{ secrets.PROD_DEPLOY_DIR }}" + + - name: Deploy to prod server + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.PROD_EC2_HOST }} + username: ${{ secrets.PROD_EC2_USER }} + key: ${{ secrets.PROD_EC2_SSH_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR="${{ secrets.PROD_DEPLOY_DIR }}/infra/prod" + chmod +x "$DEPLOY_DIR/scripts/deploy.sh" + + APP_IMAGE="${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name }}" \ + DOMAIN="${{ secrets.PROD_DOMAIN }}" \ + DEPLOY_DIR="$DEPLOY_DIR" \ + "$DEPLOY_DIR/scripts/deploy.sh" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 00000000..bf4d23a3 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,19 @@ +name: Release Drafter + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: read + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v6 + with: + config-name: release-drafter.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 9414d074..820815ce 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,8 @@ out/ /.idea/ ### Environment Variables ### -src/main/resources/.env +src/main/resources/*.env src/main/resources/*.p8 +src/test/resources/*.env .env.local .env.*.local diff --git a/CLAUDE.md b/CLAUDE.md index 5f4c558d..397539da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Weeth Server is a community platform backend built with Spring Boot 3.5.10. The codebase is in active **Java → Kotlin migration** — new code should be written in Kotlin, while ~271 Java files remain. Lombok and MapStruct are temporary dependencies being phased out in favor of Kotlin idioms and manual mappers. +Weeth Server is a community platform backend built with Spring Boot 3.5.10. The codebase has completed **Java → Kotlin migration** — all code is Kotlin. ## Build & Development Commands @@ -19,9 +19,9 @@ Weeth Server is a community platform backend built with Spring Boot 3.5.10. The ./gradlew bootRun --args='--spring.profiles.active=dev' # Run with specific profile ``` -**Prerequisites:** JDK 17, MySQL 8.0, Redis 7.0+, environment variables configured in `.env` +**Prerequisites:** JDK 21, MySQL 8.0, Redis 7.0+, environment variables configured in `.env` -**Profiles:** `local` (default dev), `dev` (dev server, ddl-auto: update), `prod` (Swagger disabled, ddl-auto: validate), `test` +**Profiles:** `local` (default dev), `local-monitoring` (local + monitoring stack), `dev` (dev server, ddl-auto: update), `prod` (Swagger disabled, ddl-auto: validate) ## Architecture @@ -35,7 +35,7 @@ presentation → application → domain ← infrastructure - **infrastructure/**: Port implementations (Adapters for S3, external APIs, etc.) ### Domain Package Layout -Each of the 8 domains (`user`, `attendance`, `schedule`, `board`, `comment`, `file`, `penalty`, `account`) follows: +Each of the 13 domains (`user`, `attendance`, `session`, `schedule`, `board`, `comment`, `file`, `penalty`, `account`, `cardinal`, `club`, `dashboard`, `university`) follows: ``` domain/{name}/ ├── application/ @@ -52,8 +52,8 @@ domain/{name}/ │ └── service/ # Multi-entity logic only (no thin wrappers) ├── infrastructure/ # Port implementations └── presentation/ - ├── {Domain}Controller.java - └── {Domain}ResponseCode.java + ├── {Domain}Controller.kt + └── {Domain}ResponseCode.kt ``` ### Key Patterns @@ -64,21 +64,7 @@ domain/{name}/ - **`@Transactional` on UseCase only** — Domain Services have no transaction annotations ### Response Format -All API responses wrapped in `CommonResponse` with code/message/data. Success codes use `ResponseCodeInterface` enums (1xxx range), error codes use `ErrorCodeInterface` enums (2xxx domain errors, 3xxx server, 4xxx client). - -### Error Code Ranges - -| Domain | Success | Error | -|--------|---------|-------| -| Account | 11xx | 21xx | -| Attendance | 12xx | 22xx | -| Board | 13xx | 23xx | -| Comment | 140xx | 240x | -| File | 15xx | 25xx | -| Penalty | 160xx | 260x | -| Schedule | 17xx | 27xx | -| User | 18xx | 28xx | -| JWT (Global) | — | 29xx | +All API responses wrapped in `CommonResponse` with code/message/data. 5-digit code format `XDDNN`: X=category (1=Success, 2=Domain Error, 3=Infra Error, 4=Client Error), DD=domain ID, NN=sequence. See `.claude/rules/api-design.md` for full domain ID mapping and code ranges. ### Authentication JWT with symmetric key (JJWT 0.13.0), OAuth2 via Kakao and Apple. `@CurrentUser` annotation injects authenticated user ID into controller methods. @@ -91,14 +77,32 @@ JWT with symmetric key (JJWT 0.13.0), OAuth2 via Kakao and Apple. `@CurrentUser` - **Fixture pattern**: `{Entity}TestFixture` objects with factory methods in `fixture/` directories - Test architecture mirrors source: mock Repository/Reader/Port in UseCase tests, mock Port (not adapter) in application tests -## Kotlin Migration Notes +## Kotlin Migration Status + +**✅ Complete** — 452 Kotlin files (100%) + +- Java → Kotlin migration fully complete +- Lombok and MapStruct dependencies removed +- All 16 mappers migrated to manual `@Component` Mapper classes (see `.claude/rules/mapper-dto.md`) +- Entity fields use `private set` for Rich Domain Model pattern (see architecture.md) +- OSIV disabled: `spring.jpa.open-in-view: false` in `application.yml` + +## Kotlin Conventions -- New code: Kotlin. Existing Java code migrated incrementally. -- Replace Lombok with Kotlin data classes/properties -- Replace MapStruct with manual `@Component` Mapper classes (see `.claude/rules/mapper-dto.md`) - Use `?.`, `?:`, `requireNotNull` — avoid `!!` - Entities: regular `class` (not `data class`); DTOs: `data class` +- Entity setters: `private set` to enforce business logic via named methods +- Example: + ```kotlin + var name: String + private set + + fun updateName(newName: String) { + require(newName.isNotBlank()) { "Name cannot be empty" } + this.name = newName + } + ``` ## Detailed Rules -Architecture, code style, testing, API design, exception handling, transactions, git conventions, and logging rules are documented in `.claude/rules/`. Refer to those files for comprehensive guidance on each topic. \ No newline at end of file +Architecture, code style, testing, API design, exception handling, transactions, and git conventions are documented in `.claude/rules/`. Refer to those files for comprehensive guidance on each topic. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..9c26d6ba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM eclipse-temurin:21-jre-alpine + +ARG OTEL_JAVA_AGENT_VERSION=2.26.1 + +WORKDIR /app + +ADD https://repo.maven.apache.org/maven2/io/opentelemetry/javaagent/opentelemetry-javaagent/${OTEL_JAVA_AGENT_VERSION}/opentelemetry-javaagent-${OTEL_JAVA_AGENT_VERSION}.jar /otel/opentelemetry-javaagent.jar + +COPY build/libs/*.jar app.jar + +ENTRYPOINT ["sh", "-c", "if [ \"${OTEL_JAVAAGENT_ENABLED:-true}\" = \"true\" ]; then exec java -javaagent:/otel/opentelemetry-javaagent.jar -jar /app/app.jar; else exec java -jar /app/app.jar; fi"] diff --git a/Dockerfile-dev b/Dockerfile-dev deleted file mode 100644 index 0e8aefbd..00000000 --- a/Dockerfile-dev +++ /dev/null @@ -1,12 +0,0 @@ -# eclipse-temurin 17 버전의 alpine 리눅스 환경을 구성 -FROM eclipse-temurin:17-jdk-alpine - -# build가 되는 시점에 JAR_FILE이라는 변수 명에 build/libs/*.jar 선언 -# build/libs - gradle로 빌드했을 때 jar 파일이 생성되는 경로 -ARG JAR_FILE=build/libs/*.jar - -# JAR_FILE을 app.jar로 복사 -COPY ${JAR_FILE} docker-springboot.jar - -# 운영 및 개발에서 사용되는 환경 설정을 분리 -ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar", "/docker-springboot.jar"] diff --git a/Dockerfile-prod b/Dockerfile-prod deleted file mode 100644 index f2026e6a..00000000 --- a/Dockerfile-prod +++ /dev/null @@ -1,12 +0,0 @@ -# eclipse-temurin 17 버전의 alpine 리눅스 환경을 구성 -FROM eclipse-temurin:17-jdk-alpine - -# build가 되는 시점에 JAR_FILE이라는 변수 명에 build/libs/*.jar 선언 -# build/libs - gradle로 빌드했을 때 jar 파일이 생성되는 경로 -ARG JAR_FILE=build/libs/*.jar - -# JAR_FILE을 app.jar로 복사 -COPY ${JAR_FILE} docker-springboot.jar - -# 운영 및 개발에서 사용되는 환경 설정을 분리 -ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "/docker-springboot.jar"] diff --git a/README.md b/README.md index 6beb7c52..16dd7156 100644 --- a/README.md +++ b/README.md @@ -1,263 +1,155 @@ # Weeth Server -동아리 관리 서비스 백엔드 저장소 +Spring Boot 3.5.10 + Kotlin 기반 동아리 커뮤니티 플랫폼 백엔드 -> **Java → Kotlin 마이그레이션 진행 중** -> 새로운 코드는 Kotlin으로 작성되며, 기존 Java 코드(~271 파일)는 점진적으로 마이그레이션됩니다. +Java -> Kotlin 마이그레이션이 완료되어 현재 모든 애플리케이션 코드는 Kotlin으로 구성되어 있습니다. -## 📋 목차 +## 기술 스택 -- [기술 스택](#-기술-스택) -- [빠른 시작](#-빠른-시작) -- [아키텍처](#-아키텍처) -- [프로젝트 구조](#-프로젝트-구조) -- [개발 가이드](#-개발-가이드) -- [테스트](#-테스트) +| 분류 | 스택 | +|------|------| +| 언어 | Kotlin 2.1.0 | +| 프레임워크 | Spring Boot 3.5.10, Gradle 8.12 (Kotlin DSL) | +| 데이터베이스 | MySQL 8.0, Redis 7.0+, Spring Data JPA | +| 스토리지 | AWS S3 (SDK v2) | +| 인증 | JWT (JJWT 0.13.0), OAuth2 (Kakao, Apple) | +| API 문서 | SpringDoc OpenAPI 3 (Swagger UI) | +| 테스트 | Kotest 5.9.1, MockK, Testcontainers | +| 코드 품질 | ktlint 1.8.0 | +| 모니터링 | Spring Actuator, Micrometer Prometheus | -## 🛠 기술 스택 +## 빠른 시작 -### Core -- **Language**: Kotlin 2.1.0 (Java 17에서 점진적 마이그레이션) -- **Framework**: Spring Boot 3.5.10 -- **Build**: Gradle 8.12 (Kotlin DSL) - -### Database & Cache -- **Database**: MySQL 8.0 -- **Cache**: Redis 7.0+ -- **ORM**: Spring Data JPA - -### Infrastructure -- **Storage**: AWS S3 (SDK v2) -- **Auth**: JWT (JJWT 0.13.0, Symmetric Key), OAuth2 (Kakao, Apple) -- **API Docs**: SpringDoc OpenAPI 3 (Swagger UI) -- **Monitoring**: Spring Actuator, Micrometer Prometheus - -### Testing -- **Framework**: Kotest 5.9.1 (DescribeSpec, BehaviorSpec, StringSpec) -- **Mocking**: MockK 1.13.14, SpringMockK 4.0.2 -- **Integration**: Testcontainers 2.0.3 (MySQL) - -### Code Quality -- **Linter/Formatter**: ktlint 1.8.0 -- **Logging**: SLF4J + Logback, Loki aggregation - -## 🚀 빠른 시작 - -### 사전 요구사항 - -- JDK 17 -- MySQL 8.0 -- Redis 7.0+ -- Gradle 8.12 (Wrapper 포함) - -### 환경 변수 설정 - -.env` 파일 생성 or 환경변수 주입 - - -### 빌드 및 실행 +**사전 요구사항:** JDK 21, MySQL 8.0, Redis 7.0+ ```bash -# 빌드 -./gradlew clean build - -# 로컬 실행 (기본 프로파일) -./gradlew bootRun - -# 특정 프로파일로 실행 -./gradlew bootRun --args='--spring.profiles.active=dev' +./gradlew clean build # 빌드 +./gradlew bootRun # 실행 (local 프로파일) +./gradlew bootRun --args='--spring.profiles.active=local-monitoring' # 로컬 모니터링 프로필 +./gradlew bootRun --args='--spring.profiles.active=dev' # 프로파일 지정 실행 +./gradlew test # 전체 테스트 +./gradlew ktlintFormat # 자동 포맷팅 ``` ### 프로파일 -| Profile | 용도 | DDL Auto | Swagger | -|---------|------|----------|---------| -| `local` | 로컬 개발 (기본) | `update` | 활성화 | -| `dev` | 개발 서버 | `update` | 활성화 | -| `prod` | 운영 서버 | `validate` | 비활성화 | -| `test` | 테스트 실행 | `create-drop` | 비활성화 | +| Profile | DDL Auto | Swagger | +|---------|----------|---------| +| `local` (기본) | `update` | 활성화 | +| `local-monitoring` | `update` | 활성화 | +| `dev` | `update` | 활성화 | +| `prod` | `validate` | 비활성화 | -## 🏗 아키텍처 - -### 레이어 구조 +## 아키텍처 ``` presentation → application → domain ← infrastructure ``` -- **presentation**: Controller, ResponseCode 열거형 -- **application**: UseCase (command/query), DTO, Mapper, Exception, Validator -- **domain**: Entity (Rich Domain Model), VO, Enum, Repository, Port, Domain Service -- **infrastructure**: Port 구현체 (S3, 외부 API 어댑터) - -### 핵심 패턴 +- **Rich Domain Model** — Entity가 비즈니스 로직, 검증, 상태 전이를 소유 +- **UseCase = 오케스트레이션** — Command (`@Transactional`) / Query (`readOnly = true`) +- **Port-Adapter** — domain이 Port 인터페이스 소유, infrastructure가 구현 +- **No thin wrappers** — UseCase가 Repository를 직접 호출 +- **Kotlin-first** — 신규 코드는 Kotlin만 사용 -#### 1. Rich Domain Model -- Entity가 비즈니스 로직, 검증, 상태 전이를 담당 -- UseCase는 오케스트레이션만 수행 (얇은 조정 계층) +### 응답 코드 형식 (`XDDNN`) -#### 2. Port-Adapter Pattern -- `domain/port/`: 도메인 언어로 작성된 인터페이스 (예: `FileStorage`, `PushNotificationSender`) -- `infrastructure/`: 기술 구현체 (예: `S3FileStorage`, `FcmPushNotificationSender`) -- UseCase는 Port 인터페이스만 의존 → 테스트 용이, 교체 가능 +| X | 분류 | +|---|------| +| 1 | 성공 | +| 2 | 도메인 에러 | +| 3 | 인프라/서버 에러 | +| 4 | 클라이언트/검증 에러 | -#### 3. UseCase 분리 -- **Command UseCase** (`usecase/command/`): 상태 변경, `@Transactional` -- **Query Service** (`usecase/query/`): 읽기 전용, `@Transactional(readOnly = true)` +DD = 도메인 ID (2자리), NN = 순번 (00~99) -#### 4. 도메인 간 참조 -- **읽기**: 대상 도메인의 Reader 인터페이스 사용 -- **쓰기 (동일 트랜잭션)**: Repository 직접 호출 -- **쓰기 (트랜잭션 분리)**: Domain Event 활용 +## 프로젝트 구조 -### 응답 형식 - -모든 API 응답은 `CommonResponse`로 래핑: - -```json -{ - "code": 1100, - "message": "사용자 조회 성공", - "data": { ... } -} ``` - -- **성공 코드**: `1xxx` (도메인별 `*ResponseCode` 열거형) -- **에러 코드**: `2xxx` (도메인 에러), `3xxx` (서버), `4xxx` (클라이언트) - -### 에러 코드 범위 - -| Domain | Success | Error | -|--------|---------|-------| -| Account | 11xx | 21xx | -| Attendance | 12xx | 22xx | -| Board | 13xx | 23xx | -| Comment | 14xx | 24xx | -| File | 15xx | 25xx | -| Penalty | 16xx | 26xx | -| Schedule | 17xx | 27xx | -| User | 18xx | 28xx | -| JWT (전역) | — | 29xx | - -## 📁 프로젝트 구조 - -``` -src/main/ -├── java/com/weeth/ # 레거시 Java 코드 (~271 파일, 점진적 마이그레이션) -└── kotlin/weeth/ - ├── domain/ - │ ├── user/ # 사용자 관리 - │ ├── attendance/ # 출석 관리 - │ ├── schedule/ # 일정 관리 (Event, Meeting) - │ ├── board/ # 게시판 (Notice, Post) - │ ├── comment/ # 댓글 - │ ├── file/ # 파일 업로드 (S3) - │ ├── penalty/ # 페널티 - │ └── account/ # 회계 - └── global/ - ├── auth/ # JWT, OAuth2, @CurrentUser - ├── config/ # Spring Configuration - └── common/ # 공통 유틸, 응답 포맷 - -각 도메인 내부 구조: -domain/{name}/ -├── application/ -│ ├── dto/request/ -│ ├── dto/response/ -│ ├── mapper/ # 수동 Mapper (MapStruct 대체) -│ ├── usecase/command/ # 상태 변경 UseCase -│ ├── usecase/query/ # 읽기 전용 QueryService -│ ├── exception/ # {Domain}ErrorCode enum -│ └── validator/ +src/main/kotlin/com/weeth/ ├── domain/ -│ ├── entity/ # JPA Entity (비즈니스 로직 포함) -│ ├── vo/ # Value Object (@Embeddable, value class) -│ ├── enums/ -│ ├── repository/ # JpaRepository + Reader 인터페이스 -│ ├── port/ # 외부 시스템 추상화 인터페이스 -│ └── service/ # 다중 엔티티 로직만 (얇은 래퍼 금지) -├── infrastructure/ # Port 구현체 -└── presentation/ - └── {Domain}Controller.kt +│ ├── user/ # 사용자 관리, 소셜 로그인 +│ ├── attendance/ # 출석 체크 +│ ├── session/ # 스터디 세션 관리 +│ ├── schedule/ # 일정 관리 +│ ├── board/ # 게시판, 게시글 CRUD +│ ├── comment/ # 댓글, 대댓글 +│ ├── file/ # 파일 업로드 (S3) +│ ├── penalty/ # 페널티 관리 +│ ├── account/ # 회계, 영수증 관리 +│ ├── cardinal/ # 기수 관리 +│ ├── club/ # 동아리 관리 +│ ├── dashboard/ # 대시보드 집계/조회 +│ └── university/ # 대학 정보 관리 +└── global/ + ├── auth/ # JWT, OAuth2, @CurrentUser + ├── config/ # Spring 설정 + ├── common/ # 공통 유틸, 응답 포맷 + └── logging/ # 요청/모니터링 로깅 ``` -## 💻 개발 가이드 - -### 코드 포맷팅 - -```bash -# 자동 포맷 (커밋 전 필수) -./gradlew ktlintFormat +## Kotlin 마이그레이션 상태 -# 검사만 수행 -./gradlew ktlintCheck -``` +- 452개 Kotlin 파일로 전환 완료 +- Lombok, MapStruct 제거 +- 16개 매퍼를 수동 `@Component` Mapper로 통일 +- Entity 필드는 `private set` 기반의 Rich Domain Model 패턴 적용 +## 인프라 -### Git 컨벤션 +### 배포 아키텍처 -#### 브랜치 네이밍 ``` -feat/{TICKET}-description # 예: feat/WTH-123-user-login -fix/{TICKET}-description # 예: fix/WTH-456-token-expiry -refactor/{TICKET}-description +GitHub Actions (CI/CD) +├── CI: PR/Push → ktlint 검사 → 빌드 및 테스트 +├── Deploy-Dev: dev 브랜치 → Docker Hub → EC2 배포 +└── Deploy-Prod: Release 발행 → Docker Hub → EC2 배포 + +EC2 (ARM64) +├── Caddy 2.8 (리버스 프록시, 자동 HTTPS) +├── Spring Boot App (Blue-Green 배포) +│ ├── app-blue (:18081) +│ └── app-green (:18082) +├── MySQL 8.0 (Dev만, Prod는 RDS) +└── Redis 7.0 ``` -#### 커밋 메시지 -``` -type: message +### Blue-Green 배포 -예시: -feat: Add user authentication -fix: Resolve null pointer in UserService -refactor: Extract validation logic to Entity -test: Add UserUseCase integration tests -``` +무중단 배포를 위해 Blue-Green 방식을 사용합니다. -## 🧪 테스트 +1. 새 컨테이너(Blue/Green) 시작 +2. `/actuator/health` 헬스 체크 (20회, 3초 간격) +3. Caddy upstream 전환 +4. 이전 컨테이너 종료 -### 실행 +### CI/CD 파이프라인 -```bash -# 전체 테스트 -./gradlew test +| 워크플로우 | 트리거 | 동작 | +|-----------|--------|------| +| CI | PR / Push (dev, main) | ktlint 검사 → 빌드 → 테스트 | +| Deploy Dev | CI 완료 (dev) | Docker 빌드 (ARM64) → Docker Hub → EC2 배포 | +| Deploy Prod | Release 발행 | Docker 빌드 (ARM64) → Docker Hub → EC2 배포 | -# 패턴 매칭 -./gradlew test --tests "*UseCaseTest" +### 모니터링 -# 특정 클래스 -./gradlew test --tests "CreateUserUseCaseTest" -``` - -## 🐳 Docker +- `/actuator/health` — 헬스 체크 (배포 시 사용) +- `/actuator/prometheus` — Prometheus 메트릭 노출 -```bash -# 개발 환경 빌드 -docker build -f Dockerfile-dev -t weeth-server:dev . +### 인프라 파일 구조 -# 운영 환경 빌드 -docker build -f Dockerfile-prod -t weeth-server:prod . +``` +infra/ +├── dev/ +│ ├── docker-compose.yml # Dev 환경 (Caddy + MySQL + Redis + App) +│ ├── caddy/Caddyfile # Caddy 리버스 프록시 설정 +│ └── scripts/deploy.sh # Blue-Green 배포 스크립트 +└── prod/ + ├── docker-compose.yml # Prod 환경 (Caddy + Redis + App, MySQL은 RDS) + ├── caddy/Caddyfile + └── scripts/deploy.sh ``` +## 라이선스 -## 📝 주요 기능 - -### 인증/인가 -- JWT 기반 인증 (대칭키, JJWT 0.13.0) -- OAuth2 소셜 로그인 (Kakao, Apple) -- Access Token / Refresh Token - -### 도메인 기능 -- **User**: 사용자 관리, 소셜 로그인, 프로필 관리 -- **Attendance**: 출석 체크, 출석 기록 조회 -- **Schedule**: 일정 생성/조회/관리 (Event, Meeting) -- **Board**: 게시판, 게시글 CRUD (Notice, Post) -- **Comment**: 댓글 CRUD, 대댓글 -- **File**: 파일 업로드 (AWS S3), 이미지 관리 -- **Penalty**: 페널티 관리 -- **Account**: 회계 관리, 영수증 관리 - - -## 📄 라이선스 - -Copyright © 2024 Weeth Team +Copyright 2024 Weeth Team diff --git a/build.gradle.kts b/build.gradle.kts index 57ef4aa0..7b7a3257 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,10 +17,14 @@ version = "0.0.1-SNAPSHOT" java { toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(21)) } } +kotlin { + jvmToolchain(21) +} + repositories { mavenCentral() } @@ -33,21 +37,7 @@ val testcontainersBomVersion = "2.0.3" val kotestVersion = "5.9.1" val mockkVersion = "1.13.14" val springmockkVersion = "4.0.2" -val lombokVersion = "1.18.36" -val mapstructVersion = "1.6.3" - dependencies { - // --- Lombok (temporary, will be removed during Kotlin migration) --- - compileOnly("org.projectlombok:lombok:$lombokVersion") - annotationProcessor("org.projectlombok:lombok:$lombokVersion") - testCompileOnly("org.projectlombok:lombok:$lombokVersion") - testAnnotationProcessor("org.projectlombok:lombok:$lombokVersion") - - // --- MapStruct (temporary, will be removed during Kotlin migration) --- - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - testAnnotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - // --- Kotlin --- implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") @@ -58,19 +48,33 @@ dependencies { developmentOnly("org.springframework.boot:spring-boot-devtools") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-aop") // Redis implementation("org.springframework.boot:spring-boot-starter-data-redis") + // Cache + implementation("org.springframework.boot:spring-boot-starter-cache") + // Actuator + Prometheus implementation("org.springframework.boot:spring-boot-starter-actuator") runtimeOnly("io.micrometer:micrometer-registry-prometheus") + // Logging + implementation("net.logstash.logback:logstash-logback-encoder:8.0") + implementation("com.github.loki4j:loki-logback-appender:1.5.2") + + // Tracing API (runtime spans are produced by the OpenTelemetry Java Agent) + implementation("io.opentelemetry:opentelemetry-api") + // --- JWT --- implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion") runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion") runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtVersion") + // --- TSID --- + implementation("io.hypersistence:hypersistence-tsid:2.1.4") + // --- DB --- runtimeOnly("com.mysql:mysql-connector-j") @@ -112,12 +116,18 @@ tasks.withType().configureEach { "-Xjsr305=strict", "-Xjvm-default=all", ) - jvmTarget.set(JvmTarget.JVM_17) + jvmTarget.set(JvmTarget.JVM_21) } } tasks.test { - useJUnitPlatform() + val runPerformanceTests = (findProperty("runPerformanceTests") as String?)?.toBoolean() ?: false + systemProperty("runPerformanceTests", runPerformanceTests.toString()) + useJUnitPlatform { + if (!runPerformanceTests) { + excludeTags("performance") + } + } } // plain jar 파일 생성 방지 (bootJar는 그대로) diff --git a/infra/dev/caddy/Caddyfile b/infra/dev/caddy/Caddyfile new file mode 100644 index 00000000..9572b864 --- /dev/null +++ b/infra/dev/caddy/Caddyfile @@ -0,0 +1,45 @@ +# {$DOMAIN} 은 Github Action에서 주입한다. +{$DOMAIN} { + + # 응답 압축 활성화 + # zstd: 최신 브라우저에서 더 효율적인 압축 + # gzip: 대부분 클라이언트와 호환되는 기본 압축 + encode zstd gzip + + # 보안 관련 HTTP 헤더 설정 + header { + + # HSTS (HTTP Strict Transport Security) + Strict-Transport-Security "max-age=31536000" + + # MIME 타입 스니핑 방지 + # 브라우저가 응답 타입을 추측하지 못하도록 하여 XSS 위험 감소 + X-Content-Type-Options "nosniff" + + # 클릭재킹 방지 + X-Frame-Options "DENY" + + # Referrer 정책 설정 + Referrer-Policy "strict-origin-when-cross-origin" + } + + # /actuator/** 외부 접근 차단 (Prometheus는 Docker 내부 네트워크로 직접 스크래핑) + handle /actuator/health { + import /etc/caddy/upstream.conf + } + handle /actuator { + respond 404 + } + handle /actuator/* { + respond 404 + } + + redir {$MONITORING_PATH} {$MONITORING_PATH}/ + + handle {$MONITORING_PATH}* { + reverse_proxy grafana:3000 + } + + # 실제 reverse_proxy 설정은 upstream.conf 파일에서 불러옴 + import /etc/caddy/upstream.conf +} diff --git a/infra/dev/caddy/upstream.conf b/infra/dev/caddy/upstream.conf new file mode 100644 index 00000000..74cf5084 --- /dev/null +++ b/infra/dev/caddy/upstream.conf @@ -0,0 +1 @@ +reverse_proxy weeth-dev-app-blue:8080 diff --git a/infra/dev/docker-compose.yml b/infra/dev/docker-compose.yml new file mode 100644 index 00000000..84d3d5ff --- /dev/null +++ b/infra/dev/docker-compose.yml @@ -0,0 +1,135 @@ +name: weeth-dev + +services: + caddy: + image: caddy:2.8 + container_name: weeth-dev-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./caddy/upstream.conf:/etc/caddy/upstream.conf + - caddy_data:/data + - caddy_config:/config + environment: + DOMAIN: ${DOMAIN} + MONITORING_PATH: ${MONITORING_PATH} + networks: + - web + + mysql: + image: mysql:8.0 + container_name: weeth-dev-mysql + restart: unless-stopped + env_file: + - .env + environment: + TZ: Asia/Seoul + ports: + - "127.0.0.1:3306:3306" + volumes: + - mysql_data:/var/lib/mysql + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + networks: + - web + + redis: + image: redis:7.0 + container_name: weeth-dev-redis + restart: unless-stopped + ports: + - "127.0.0.1:6379:6379" + volumes: + - redis_data:/data + networks: + - web + + app-blue: + image: ${APP_IMAGE} + container_name: weeth-dev-app-blue + restart: unless-stopped + profiles: ["blue"] + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: dev + TZ: Asia/Seoul + OTEL_JAVAAGENT_ENABLED: ${OTEL_JAVAAGENT_ENABLED:-true} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-weeth-server} + OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=dev} + OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} + OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} + OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-0.1} + OTEL_BSP_SCHEDULE_DELAY: ${OTEL_BSP_SCHEDULE_DELAY:-15000} + OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} + OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_EXPORTER_OTLP_COMPRESSION: ${OTEL_EXPORTER_OTLP_COMPRESSION:-gzip} + volumes: + - ${HOME}/keys:/app/keys:ro + ports: + - "127.0.0.1:18081:8080" + depends_on: + mysql: + condition: service_started + redis: + condition: service_started + logging: + driver: json-file + options: + max-size: "50m" + max-file: "3" + networks: + - web + + app-green: + image: ${APP_IMAGE} + container_name: weeth-dev-app-green + restart: unless-stopped + profiles: ["green"] + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: dev + TZ: Asia/Seoul + OTEL_JAVAAGENT_ENABLED: ${OTEL_JAVAAGENT_ENABLED:-true} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-weeth-server} + OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=dev} + OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} + OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} + OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-0.1} + OTEL_BSP_SCHEDULE_DELAY: ${OTEL_BSP_SCHEDULE_DELAY:-15000} + OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} + OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_EXPORTER_OTLP_COMPRESSION: ${OTEL_EXPORTER_OTLP_COMPRESSION:-gzip} + volumes: + - ${HOME}/keys:/app/keys:ro + ports: + - "127.0.0.1:18082:8080" + depends_on: + mysql: + condition: service_started + redis: + condition: service_started + logging: + driver: json-file + options: + max-size: "50m" + max-file: "3" + networks: + - web + +networks: + web: + driver: bridge + +volumes: + caddy_data: + caddy_config: + mysql_data: + redis_data: diff --git a/infra/dev/monitoring/alloy/config.alloy b/infra/dev/monitoring/alloy/config.alloy new file mode 100644 index 00000000..c501520d --- /dev/null +++ b/infra/dev/monitoring/alloy/config.alloy @@ -0,0 +1,76 @@ +discovery.docker "weeth" { + host = "unix:///var/run/docker.sock" + + filter { + name = "name" + values = ["weeth-dev-app"] + } +} + +loki.source.docker "app_logs" { + host = "unix:///var/run/docker.sock" + targets = discovery.docker.weeth.targets + forward_to = [loki.process.parse_json.receiver] +} + +loki.process "parse_json" { + stage.static_labels { + values = { app = "weeth", env = "dev" } + } + + stage.json { + expressions = { + level = "level", + logger_name = "logger_name", + } + } + + stage.labels { + values = { level = "", logger_name = "" } + } + + stage.match { + selector = "{logger_name=\"ACCESS_LOG\"}" + stage.static_labels { values = { log_type = "access" } } + } + stage.match { + selector = "{logger_name=\"AUDIT_LOG\"}" + stage.static_labels { values = { log_type = "audit" } } + } + stage.match { + selector = "{logger_name=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "error" } } + } + stage.match { + selector = "{logger_name!=\"ACCESS_LOG\", logger_name!=\"AUDIT_LOG\", logger_name!=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "application" } } + } + + stage.label_drop { values = ["logger_name"] } + + forward_to = [loki.write.default.receiver] +} + +loki.write "default" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} + +otelcol.receiver.otlp "default" { + http { + endpoint = "0.0.0.0:4318" + } + output { + traces = [otelcol.exporter.otlp.tempo.input] + } +} + +otelcol.exporter.otlp "tempo" { + client { + endpoint = "tempo:4317" + tls { + insecure = true + } + } +} diff --git a/infra/dev/monitoring/docker-compose.yml b/infra/dev/monitoring/docker-compose.yml new file mode 100644 index 00000000..cfb93f78 --- /dev/null +++ b/infra/dev/monitoring/docker-compose.yml @@ -0,0 +1,143 @@ +name: weeth-dev-monitoring + +services: + alloy: + image: grafana/alloy:v1.9.0 + group_add: + - "${DOCKER_GID}" + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - ./alloy/config.alloy:/etc/alloy/config.alloy:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + command: ["run", "--server.http.listen-addr=0.0.0.0:12345", "/etc/alloy/config.alloy"] + ports: + - "127.0.0.1:12345:12345" + - "127.0.0.1:4318:4318" + depends_on: + - loki + - tempo + networks: + - monitoring + - weeth-app + restart: unless-stopped + + loki: + image: grafana/loki:3.4.2 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - ./loki/loki-config.yaml:/etc/loki/loki-config.yaml:ro + - loki_data:/loki + command: ["-config.file=/etc/loki/loki-config.yaml", "-config.expand-env=true"] + ports: + - "127.0.0.1:3100:3100" + networks: + - monitoring + restart: unless-stopped + + tempo: + image: grafana/tempo:2.7.1 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - ./tempo/tempo-config.yaml:/etc/tempo/tempo-config.yaml:ro + - tempo_data:/var/tempo + command: ["-config.file=/etc/tempo/tempo-config.yaml", "-config.expand-env=true"] + ports: + - "127.0.0.1:3200:3200" + networks: + - monitoring + restart: unless-stopped + + redis-exporter: + image: oliver006/redis_exporter:v1.67.0 + environment: + REDIS_ADDR: redis:6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + networks: + - monitoring + - weeth-app + restart: unless-stopped + + prometheus: + image: prom/prometheus:v2.53.0 + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + ports: + - "127.0.0.1:9090:9090" + networks: + - monitoring + - weeth-app + restart: unless-stopped + + node-exporter: + image: prom/node-exporter:v1.9.0 + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - "--path.procfs=/host/proc" + - "--path.sysfs=/host/sys" + - "--path.rootfs=/rootfs" + - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)" + networks: + - monitoring + restart: unless-stopped + + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.51.0 + privileged: true + devices: + - /dev/kmsg + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker:/var/lib/docker:ro + - /dev/disk:/dev/disk:ro + networks: + - monitoring + - weeth-app + restart: unless-stopped + + grafana: + image: grafana/grafana:11.5.2 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-https://${DOMAIN}${MONITORING_PATH}/} + GF_SERVER_SERVE_FROM_SUB_PATH: "true" + GF_AUTH_ANONYMOUS_ENABLED: "false" + ports: + - "127.0.0.1:3000:3000" + depends_on: + - loki + - prometheus + - tempo + networks: + - monitoring + - weeth-app + restart: unless-stopped + +networks: + monitoring: + driver: bridge + weeth-app: + external: true + name: weeth-dev_web + +volumes: + grafana_data: + loki_data: + prometheus_data: + tempo_data: diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/api-overview.json b/infra/dev/monitoring/grafana/provisioning/dashboards/api-overview.json new file mode 100644 index 00000000..f7cf5807 --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/api-overview.json @@ -0,0 +1,388 @@ +{ + "uid": "weeth-api-overview", + "title": "API Overview", + "tags": ["weeth", "api"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "templating": { + "list": [ + { + "name": "uri", + "type": "query", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "query": "label_values(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\"}, uri)", + "includeAll": true, + "multi": true, + "current": { "text": "All", "value": "$__all" }, + "refresh": 2 + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Total Requests / min", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) * 60", + "legendFormat": "req/min" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqpm", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 100 }, + { "color": "red", "value": 500 } + ] + } + } + } + }, + { + "id": 2, + "title": "5xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "5xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + } + } + }, + { + "id": 3, + "title": "4xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "4xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 20 } + ] + } + } + } + }, + { + "id": 16, + "title": "Avg Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_sum{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)", + "legendFormat": "avg" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.3 }, + { "color": "red", "value": 0.5 } + ] + } + } + } + }, + { + "id": 4, + "title": "Apdex (0.5s)", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex score (사용자 체감 만족도). 1.0=최고, >0.94=훌륭함, >0.85=좋음, >0.7=나쁨, <0.7=매우 나쁨", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + } + } + } + }, + { + "id": 5, + "title": "P95 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P95" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 6, + "title": "P99 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 2 } + ] + } + } + } + }, + { + "id": 7, + "title": "Requests per Second", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "total" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"2..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "2xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "4xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "5xx" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "Response Time Distribution", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "엔드포인트별 P50/P95/P99 응답시간 추이. 기본(All)은 전체 엔드포인트별 분포를 표시하고, 상단 uri 필터에서 특정 엔드포인트를 선택하면 해당 API만 표시됩니다.", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P50" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P95" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 9, + "title": "HTTP Status Code Distribution", + "type": "piechart", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(status) (increase(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1h]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "overrides": [ + { "matcher": { "id": "byRegexp", "options": "^2\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^3\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^4\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^5\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] } + ] + } + }, + { + "id": 10, + "title": "Slowest Endpoints (P95)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\"}[5m])) by (le, uri)))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 11, + "title": "Top 10 Error Endpoints (5xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 12, + "title": "Request Rate by Endpoint", + "type": "timeseries", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(uri, method) (rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "{{method}} {{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 13, + "title": "Top 10 High-Traffic Endpoints (4xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 14, + "title": "Apdex Over Time (0.5s)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex trend. T=0.5s (satisfied), 4T=2.0s (tolerating). Below 0.85 = degraded experience.", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + }, + "color": { "mode": "continuous-GrYlRd" } + } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/dashboards.yaml b/infra/dev/monitoring/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 00000000..2a76fbbd --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/dashboards.yaml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: weeth + orgId: 1 + folder: Weeth + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards + foldersFromFilesStructure: false diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/external-infra.json b/infra/dev/monitoring/grafana/provisioning/dashboards/external-infra.json new file mode 100644 index 00000000..f38d9eb7 --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/external-infra.json @@ -0,0 +1,373 @@ +{ + "uid": "weeth-external-infra", + "title": "External Infrastructure", + "tags": ["weeth", "db", "redis"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "HikariCP (MySQL)", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "collapsed": false, + "panels": [] + }, + { + "id": 2, + "title": "Active", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 8 } + ] + } + } + } + }, + { + "id": 3, + "title": "Idle", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "green", "value": 3 } + ] + } + } + } + }, + { + "id": 4, + "title": "Pending", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 3 } + ] + } + } + } + }, + { + "id": 5, + "title": "Total / Max", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections", + "legendFormat": "total" + }, + { + "expr": "hikaricp_connections_max", + "legendFormat": "max" + } + ] + }, + { + "id": 6, + "title": "Timeout Total", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "커넥션 획득 timeout 누적 횟수. 0이 아니면 커넥션 풀 고갈 발생.", + "targets": [ + { + "expr": "hikaricp_connections_timeout_total", + "legendFormat": "timeouts" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 7, + "title": "Timeout Rate / min", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_timeout_total[1m]) * 60", + "legendFormat": "timeouts/min" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 0.1 } + ] + } + } + } + }, + { + "id": 8, + "title": "Connection Pool Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + }, + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + }, + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + }, + { + "expr": "hikaricp_connections", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "Connection Acquire Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_acquire_seconds_sum[1m]) / rate(hikaricp_connections_acquire_seconds_count[1m])", + "legendFormat": "avg acquire time" + }, + { + "expr": "hikaricp_connections_acquire_seconds_max", + "legendFormat": "max acquire time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "Connection Creation Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_creation_seconds_sum[1m]) / rate(hikaricp_connections_creation_seconds_count[1m])", + "legendFormat": "avg creation time" + }, + { + "expr": "hikaricp_connections_creation_seconds_max", + "legendFormat": "max creation time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Connection Usage Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_usage_seconds_sum[1m]) / rate(hikaricp_connections_usage_seconds_count[1m])", + "legendFormat": "avg usage time" + }, + { + "expr": "hikaricp_connections_usage_seconds_max", + "legendFormat": "max usage time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 12, + "title": "Redis", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 13 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Redis Command Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "commands/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 14, + "title": "Redis Command Latency", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(lettuce_command_completion_seconds_sum[1m]) / rate(lettuce_command_completion_seconds_count[1m])", + "legendFormat": "avg latency" + }, + { + "expr": "lettuce_command_completion_seconds_max", + "legendFormat": "max latency" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 15, + "title": "Redis Command by Type", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(command) (rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "{{command}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 16, + "title": "Redis Command Errors", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_firstresponse_seconds_count{outcome=\"ERROR\"}[1m]))", + "legendFormat": "errors/sec" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { "fillOpacity": 10 }, + "color": { "fixedColor": "red", "mode": "fixed" } + } + } + }, + { + "id": 17, + "title": "Redis Cache Hit Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Redis 서버 전체 캐시 히트율. redis_exporter의 keyspace_hits/misses 기반.", + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[5m]) / clamp_min(rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]), 0.001) * 100", + "legendFormat": "hit rate" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "green", "value": 80 } + ] + } + } + } + }, + { + "id": 18, + "title": "Redis Cache Hits / Misses", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[1m])", + "legendFormat": "hits/sec" + }, + { + "expr": "rate(redis_keyspace_misses_total[1m])", + "legendFormat": "misses/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/internal-infra.json b/infra/dev/monitoring/grafana/provisioning/dashboards/internal-infra.json new file mode 100644 index 00000000..50ed2b9e --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/internal-infra.json @@ -0,0 +1,334 @@ +{ + "uid": "weeth-internal-infra", + "title": "Internal Infrastructure", + "tags": ["weeth", "jvm", "infra"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "Uptime", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_uptime_seconds", + "legendFormat": "uptime" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + } + }, + { + "id": 2, + "title": "Heap Usage %", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"}) / sum(jvm_memory_max_bytes{area=\"heap\"}) * 100", + "legendFormat": "heap %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 70 }, + { "color": "red", "value": 90 } + ] + } + } + } + }, + { + "id": 3, + "title": "Live Threads", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + } + ] + }, + { + "id": 4, + "title": "App CPU", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage * 100", + "legendFormat": "CPU %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 60 }, + { "color": "red", "value": 85 } + ] + } + } + } + }, + { + "id": 5, + "title": "JVM", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 }, + "collapsed": false, + "panels": [] + }, + { + "id": 6, + "title": "JVM Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"heap\"}", + "legendFormat": "{{id}} used" + }, + { + "expr": "jvm_memory_committed_bytes{area=\"heap\"}", + "legendFormat": "{{id}} committed" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 7, + "title": "JVM Heap Summary", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"})", + "legendFormat": "used" + }, + { + "expr": "sum(jvm_memory_committed_bytes{area=\"heap\"})", + "legendFormat": "committed" + }, + { + "expr": "sum(jvm_memory_max_bytes{area=\"heap\"})", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "JVM Non-Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"nonheap\"}", + "legendFormat": "{{id}} used" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "GC Pause Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_sum[1m])", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "GC Count / min", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_count[1m]) * 60", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Thread Count Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + }, + { + "expr": "jvm_threads_daemon_threads", + "legendFormat": "daemon" + }, + { + "expr": "jvm_threads_peak_threads", + "legendFormat": "peak" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 12, + "title": "Tomcat & System", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 3 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Tomcat Threads", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "tomcat_threads_current_threads", + "legendFormat": "current" + }, + { + "expr": "tomcat_threads_busy_threads", + "legendFormat": "busy" + }, + { + "expr": "tomcat_threads_config_max_threads", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 14, + "title": "CPU Usage", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage", + "legendFormat": "app CPU" + }, + { + "expr": "system_cpu_usage", + "legendFormat": "system CPU" + } + ], + "fieldConfig": { + "defaults": { "unit": "percentunit", "max": 1, "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 15, + "title": "Host & Docker", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 37 }, + "collapsed": false, + "panels": [] + }, + { + "id": 16, + "title": "Host Memory Used / Total", + "type": "stat", + "gridPos": { "h": 4, "w": 12, "x": 0, "y": 38 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes", + "legendFormat": "used" + }, + { + "expr": "node_memory_MemTotal_bytes", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes" } + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "textMode": "value_and_name", + "wideLayout": true + } + }, + { + "id": 17, + "title": "Swap Used / Total", + "type": "stat", + "gridPos": { "h": 4, "w": 12, "x": 12, "y": 38 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes", + "legendFormat": "used" + }, + { + "expr": "node_memory_SwapTotal_bytes", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes" } + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "textMode": "value_and_name", + "wideLayout": true + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/logs-explorer.json b/infra/dev/monitoring/grafana/provisioning/dashboards/logs-explorer.json new file mode 100644 index 00000000..9b7f8669 --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/logs-explorer.json @@ -0,0 +1,403 @@ +{ + "uid": "weeth-logs-explorer", + "title": "Logs Explorer", + "tags": [ + "weeth", + "logs" + ], + "timezone": "Asia/Seoul", + "refresh": "30s", + "time": { + "from": "now-30m", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "log_type", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "access", + "value": "access" + }, + { + "text": "audit", + "value": "audit" + }, + { + "text": "error", + "value": "error" + }, + { + "text": "application", + "value": "application" + } + ], + "includeAll": true, + "multi": false, + "query": "access,audit,error,application" + }, + { + "name": "level", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "ERROR", + "value": "ERROR" + }, + { + "text": "WARN", + "value": "WARN" + }, + { + "text": "INFO", + "value": "INFO" + }, + { + "text": "DEBUG", + "value": "DEBUG" + } + ], + "includeAll": true, + "multi": false, + "query": "ERROR,WARN,INFO,DEBUG" + }, + { + "name": "search", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Search" + }, + { + "name": "requestId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Request ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Log Volume", + "type": "timeseries", + "gridPos": { + "h": 5, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(level) (count_over_time({app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} [$__auto]))", + "legendFormat": "{{level}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "stacking": { + "mode": "normal" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "ERROR" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "WARN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "INFO" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DEBUG" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + } + }, + { + "id": 2, + "title": "Live Logs", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 41 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} | json | line_format \"{{.method}} {{.path}} {{.status}} {{.durationMs}}ms {{.message}}\" |= `$search` |= `$requestId`" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "showCommonLabels": false, + "wrapLogMessage": true, + "prettifyLogMessage": false, + "enableLogDetails": true, + "sortOrder": "Descending", + "dedupStrategy": "none" + } + }, + { + "id": 4, + "title": "Error Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 31 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"error\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 5, + "title": "Access Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 21 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 6, + "title": "Error Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"ERROR\"} [$__auto])) or vector(0)", + "legendFormat": "errors" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 7, + "title": "Warn Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"WARN\"} [$__auto])) or vector(0)", + "legendFormat": "warnings" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "yellow", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 8, + "title": "HTTP Status Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(status) (count_over_time({app=\"weeth\", log_type=\"access\"} | json [$__auto]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 9, + "title": "Slow Requests (> 1s)", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 55 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json | durationMs > 1000" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/trace-explorer.json b/infra/dev/monitoring/grafana/provisioning/dashboards/trace-explorer.json new file mode 100644 index 00000000..8be83176 --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/trace-explorer.json @@ -0,0 +1,513 @@ +{ + "uid": "weeth-trace-explorer", + "title": "Trace Explorer", + "tags": [ + "weeth", + "trace" + ], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { + "from": "now-6h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "traceId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Trace ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "All Traces", + "description": "최근 전체 트레이스 목록입니다. actuator/health-check는 애플리케이션 observation 필터에서 제외합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{}", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 6, + "title": "Trace Search", + "description": "Tempo Explore에서 TraceQL로 검색합니다.", + "type": "text", + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 10 + }, + "options": { + "mode": "markdown", + "content": "TraceId를 선택하면 Tempo Explore에서 해당 TraceId를 바로 조회합니다.\n\n기본 조회 범위는 최근 6시간, 목록 제한은 200건입니다." + } + }, + { + "id": 4, + "title": "Error Traces", + "description": "에러가 발생한 트레이스를 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 14 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ status = error }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 3, + "title": "Slow Traces (> 500ms)", + "description": "500ms 이상 걸린 요청의 트레이스를 자동 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 24 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ duration > 500ms }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 5, + "title": "Related Logs (by Trace ID)", + "description": "Trace ID를 입력하면 해당 트레이스와 연관된 로그를 확인합니다.", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 34 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\"} |= `$traceId` | json", + "refId": "A" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/dev/monitoring/grafana/provisioning/datasources/datasources.yaml b/infra/dev/monitoring/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 00000000..f6118e0a --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/datasources/datasources.yaml @@ -0,0 +1,31 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + uid: prometheus + isDefault: true + editable: true + + - name: Tempo + type: tempo + access: proxy + url: http://tempo:3200 + uid: tempo + editable: true + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + uid: loki + editable: true + jsonData: + derivedFields: + - datasourceUid: tempo + matcherRegex: '"(?:traceId|trace_id|mdc_traceId|mdc_trace_id)"\s*:\s*"([^"]+)"' + name: traceId + url: "$${__value.raw}" + urlDisplayLabel: "View Trace" diff --git a/infra/dev/monitoring/loki/loki-config.yaml b/infra/dev/monitoring/loki/loki-config.yaml new file mode 100644 index 00000000..963e9db0 --- /dev/null +++ b/infra/dev/monitoring/loki/loki-config.yaml @@ -0,0 +1,43 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + ring: + kvstore: + store: inmemory + replication_factor: 1 + path_prefix: /loki + +schema_config: + configs: + - from: "2026-04-15" + store: tsdb + object_store: s3 + schema: v13 + index: + prefix: loki_index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/index + cache_location: /loki/cache + aws: + s3: s3://${AWS_REGION}/${LOKI_S3_BUCKET} + s3forcepathstyle: false + +limits_config: + retention_period: 7d + max_label_names_per_series: 5 + max_label_value_length: 1024 + ingestion_rate_mb: 10 + ingestion_burst_size_mb: 20 + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + delete_request_store: filesystem diff --git a/infra/dev/monitoring/prometheus/prometheus.yml b/infra/dev/monitoring/prometheus/prometheus.yml new file mode 100644 index 00000000..f2424afa --- /dev/null +++ b/infra/dev/monitoring/prometheus/prometheus.yml @@ -0,0 +1,48 @@ +global: + scrape_interval: 30s + evaluation_interval: 30s + +scrape_configs: + - job_name: "weeth-app" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: ["app-blue:8080", "app-green:8080"] + labels: + app: weeth + env: dev + + - job_name: "node-exporter" + static_configs: + - targets: ["node-exporter:9100"] + labels: + env: dev + + - job_name: "cadvisor" + static_configs: + - targets: ["cadvisor:8080"] + labels: + env: dev + + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + labels: + env: dev + + - job_name: "loki" + static_configs: + - targets: ["loki:3100"] + labels: + env: dev + + - job_name: "tempo" + static_configs: + - targets: ["tempo:3200"] + labels: + env: dev + + - job_name: "redis" + static_configs: + - targets: ["redis-exporter:9121"] + labels: + env: dev diff --git a/infra/dev/monitoring/scripts/deploy.sh b/infra/dev/monitoring/scripts/deploy.sh new file mode 100644 index 00000000..9a273d6a --- /dev/null +++ b/infra/dev/monitoring/scripts/deploy.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEPLOY_DIR="${DEPLOY_DIR:-/home/ubuntu/infra/dev/monitoring}" +APP_NETWORK="${APP_NETWORK:-weeth-dev_web}" +MONITORING_ENV_FILE="${MONITORING_ENV_FILE:-$DEPLOY_DIR/.env.monitoring}" + +cd "$DEPLOY_DIR" + +if [ ! -f "$MONITORING_ENV_FILE" ]; then + echo "[monitoring] env file not found: $MONITORING_ENV_FILE" + exit 1 +fi + +if [ -z "${DOCKER_GID:-}" ]; then + DOCKER_GID="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || stat -f '%g' /var/run/docker.sock 2>/dev/null || true)" +fi + +if [ -z "${DOCKER_GID:-}" ]; then + echo "[monitoring] failed to detect docker.sock group id" + echo "[monitoring] set DOCKER_GID explicitly in $MONITORING_ENV_FILE or the shell environment" + exit 1 +fi + +export MONITORING_ENV_FILE DOCKER_GID + +if ! docker network inspect "$APP_NETWORK" >/dev/null 2>&1; then + echo "[monitoring] required docker network not found: $APP_NETWORK" + echo "[monitoring] deploy the app stack first or create the network before deploying monitoring" + exit 1 +fi + +echo "[monitoring] docker.sock gid=$DOCKER_GID" +echo "[monitoring] pulling images..." +docker compose --env-file "$MONITORING_ENV_FILE" pull + +echo "[monitoring] starting monitoring stack..." +docker compose --env-file "$MONITORING_ENV_FILE" up -d + +echo "[monitoring] waiting for services to be healthy..." +for i in {1..30}; do + if curl -fsS "http://127.0.0.1:12345/-/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:9090/-/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3100/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3200/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3000/api/health" >/dev/null 2>&1; then + echo "[monitoring] all services healthy" + break + fi + + if [ "$i" -eq 30 ]; then + echo "[monitoring] health check failed — check docker compose logs" + exit 1 + fi + + sleep 2 +done + +echo "[monitoring] deploy completed" diff --git a/infra/dev/monitoring/tempo/tempo-config.yaml b/infra/dev/monitoring/tempo/tempo-config.yaml new file mode 100644 index 00000000..ce62c027 --- /dev/null +++ b/infra/dev/monitoring/tempo/tempo-config.yaml @@ -0,0 +1,38 @@ +stream_over_http_enabled: true + +server: + http_listen_port: 3200 + +distributor: + max_attribute_bytes: 1024 + receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + +query_frontend: + max_query_expression_size_bytes: 32768 + search: + default_spans_per_span_set: 1 + max_spans_per_span_set: 20 + +storage: + trace: + backend: s3 + s3: + bucket: ${TEMPO_S3_BUCKET} + endpoint: s3.${AWS_REGION}.amazonaws.com + wal: + path: /var/tempo/wal + local: + path: /var/tempo/blocks + +compactor: + compaction: + block_retention: 168h + +overrides: + defaults: + global: + max_bytes_per_trace: 3000000 diff --git a/infra/dev/scripts/deploy.sh b/infra/dev/scripts/deploy.sh new file mode 100755 index 00000000..f62f04dd --- /dev/null +++ b/infra/dev/scripts/deploy.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Github Action에서 주입한 환경변수 사용 +: "${APP_IMAGE:?APP_IMAGE is required}" +: "${DOMAIN:?DOMAIN is required}" +: "${DEPLOY_DIR:=/opt/weeth/dev}" + +cd "$DEPLOY_DIR" + +export APP_IMAGE DOMAIN + +# EC2 홈 디렉토리의 .env를 심링크 +ln -sf "$HOME/.env" "$DEPLOY_DIR/.env" + +if [ ! -f ./caddy/upstream.conf ]; then + echo "reverse_proxy weeth-dev-app-blue:8080" > ./caddy/upstream.conf +fi + +if grep -q "app-blue" ./caddy/upstream.conf; then + NEW_COLOR="green" + NEW_HEALTH_PORT="18082" + OLD_COLOR="blue" +else + NEW_COLOR="blue" + NEW_HEALTH_PORT="18081" + OLD_COLOR="green" +fi + +echo "[deploy] image=$APP_IMAGE new_color=$NEW_COLOR old_color=$OLD_COLOR" + +docker compose --profile "$NEW_COLOR" -f docker-compose.yml pull "app-$NEW_COLOR" +docker compose --profile "$NEW_COLOR" -f docker-compose.yml up -d "app-$NEW_COLOR" + +for i in {1..20}; do + if curl -fsS "http://127.0.0.1:${NEW_HEALTH_PORT}/actuator/health" >/dev/null; then + echo "[deploy] new app is healthy" + break + fi + + if [ "$i" -eq 20 ]; then + echo "[deploy] health check failed" + exit 1 + fi + + sleep 3 +done + +echo "reverse_proxy weeth-dev-app-${NEW_COLOR}:8080" > ./caddy/upstream.conf + +# 현재 Caddy 컨테이너의 DOMAIN과 비교하여 변경 시에만 재생성 +CURRENT_DOMAIN=$(docker inspect weeth-dev-caddy --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep '^DOMAIN=' | cut -d= -f2-) + +if [ "$CURRENT_DOMAIN" != "$DOMAIN" ]; then + echo "[deploy] domain changed, recreating caddy" + docker compose up -d --force-recreate caddy +elif docker compose ps caddy --format '{{.State}}' 2>/dev/null | grep -q running; then + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile +else + docker compose up -d caddy +fi + +docker compose --profile "$OLD_COLOR" -f docker-compose.yml stop "app-$OLD_COLOR" || true +docker compose --profile "$OLD_COLOR" -f docker-compose.yml rm -f "app-$OLD_COLOR" || true + +docker image prune -f +echo "[deploy] completed" diff --git a/infra/local/monitoring/alloy/config.alloy b/infra/local/monitoring/alloy/config.alloy new file mode 100644 index 00000000..693ca47f --- /dev/null +++ b/infra/local/monitoring/alloy/config.alloy @@ -0,0 +1,75 @@ +// Loki push API 수신 — 앱에서 HTTP로 로그를 직접 push +loki.source.api "local_push" { + http { + listen_address = "0.0.0.0" + listen_port = 3101 + } + forward_to = [loki.process.parse_json.receiver] +} + +// JSON 파싱 및 라벨 매핑 +loki.process "parse_json" { + stage.json { + expressions = { + level = "level", + logger_name = "logger_name", + } + } + + stage.labels { + values = { level = "", logger_name = "" } + } + + // logger_name 기반으로 log_type 매핑 + stage.match { + selector = "{logger_name=\"ACCESS_LOG\"}" + stage.static_labels { values = { log_type = "access" } } + } + stage.match { + selector = "{logger_name=\"AUDIT_LOG\"}" + stage.static_labels { values = { log_type = "audit" } } + } + stage.match { + selector = "{logger_name=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "error" } } + } + + // 위 매칭에 걸리지 않은 로그 → application + stage.match { + selector = "{logger_name!=\"ACCESS_LOG\", logger_name!=\"AUDIT_LOG\", logger_name!=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "application" } } + } + + stage.label_drop { values = ["logger_name"] } + + forward_to = [loki.write.default.receiver] +} + +// Loki로 전송 +loki.write "default" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} + +// === 트레이스 수집 파이프라인 === + +// OTLP HTTP receiver — 앱에서 트레이스 수신 +otelcol.receiver.otlp "default" { + http { + endpoint = "0.0.0.0:4318" + } + output { + traces = [otelcol.exporter.otlp.tempo.input] + } +} + +// Tempo로 트레이스 전달 +otelcol.exporter.otlp "tempo" { + client { + endpoint = "tempo:4317" + tls { + insecure = true + } + } +} diff --git a/infra/local/monitoring/docker-compose.yml b/infra/local/monitoring/docker-compose.yml new file mode 100644 index 00000000..4e22df6c --- /dev/null +++ b/infra/local/monitoring/docker-compose.yml @@ -0,0 +1,76 @@ +name: weeth-local-monitoring + +services: + alloy: + image: grafana/alloy:v1.9.0 + volumes: + - ./alloy/config.alloy:/etc/alloy/config.alloy + command: ["run", "--server.http.listen-addr=0.0.0.0:12345", "/etc/alloy/config.alloy"] + ports: + - "12345:12345" + - "3101:3101" + - "4318:4318" + depends_on: + - loki + - tempo + restart: unless-stopped + + loki: + image: grafana/loki:3.4.2 + volumes: + - ./loki/loki-config.yaml:/etc/loki/loki-config.yaml + - loki_data:/loki + command: ["-config.file=/etc/loki/loki-config.yaml"] + ports: + - "3100:3100" + restart: unless-stopped + + tempo: + image: grafana/tempo:2.7.1 + volumes: + - ./tempo/tempo-config.yaml:/etc/tempo/tempo-config.yaml + - tempo_data:/var/tempo + command: ["-config.file=/etc/tempo/tempo-config.yaml"] + ports: + - "127.0.0.1:3200:3200" + restart: unless-stopped + + redis-exporter: + image: oliver006/redis_exporter:v1.67.0 + environment: + - REDIS_ADDR=host.docker.internal:6379 + ports: + - "127.0.0.1:9121:9121" + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + + prometheus: + image: prom/prometheus:v2.53.0 + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + + grafana: + image: grafana/grafana:11.5.2 + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + ports: + - "3000:3000" + depends_on: + - loki + - prometheus + - tempo + restart: unless-stopped + +volumes: + loki_data: + tempo_data: diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/api-overview.json b/infra/local/monitoring/grafana/provisioning/dashboards/api-overview.json new file mode 100644 index 00000000..f7cf5807 --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/api-overview.json @@ -0,0 +1,388 @@ +{ + "uid": "weeth-api-overview", + "title": "API Overview", + "tags": ["weeth", "api"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "templating": { + "list": [ + { + "name": "uri", + "type": "query", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "query": "label_values(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\"}, uri)", + "includeAll": true, + "multi": true, + "current": { "text": "All", "value": "$__all" }, + "refresh": 2 + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Total Requests / min", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) * 60", + "legendFormat": "req/min" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqpm", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 100 }, + { "color": "red", "value": 500 } + ] + } + } + } + }, + { + "id": 2, + "title": "5xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "5xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + } + } + }, + { + "id": 3, + "title": "4xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "4xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 20 } + ] + } + } + } + }, + { + "id": 16, + "title": "Avg Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_sum{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)", + "legendFormat": "avg" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.3 }, + { "color": "red", "value": 0.5 } + ] + } + } + } + }, + { + "id": 4, + "title": "Apdex (0.5s)", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex score (사용자 체감 만족도). 1.0=최고, >0.94=훌륭함, >0.85=좋음, >0.7=나쁨, <0.7=매우 나쁨", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + } + } + } + }, + { + "id": 5, + "title": "P95 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P95" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 6, + "title": "P99 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 2 } + ] + } + } + } + }, + { + "id": 7, + "title": "Requests per Second", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "total" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"2..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "2xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "4xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "5xx" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "Response Time Distribution", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "엔드포인트별 P50/P95/P99 응답시간 추이. 기본(All)은 전체 엔드포인트별 분포를 표시하고, 상단 uri 필터에서 특정 엔드포인트를 선택하면 해당 API만 표시됩니다.", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P50" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P95" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 9, + "title": "HTTP Status Code Distribution", + "type": "piechart", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(status) (increase(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1h]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "overrides": [ + { "matcher": { "id": "byRegexp", "options": "^2\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^3\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^4\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^5\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] } + ] + } + }, + { + "id": 10, + "title": "Slowest Endpoints (P95)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\"}[5m])) by (le, uri)))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 11, + "title": "Top 10 Error Endpoints (5xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 12, + "title": "Request Rate by Endpoint", + "type": "timeseries", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(uri, method) (rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "{{method}} {{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 13, + "title": "Top 10 High-Traffic Endpoints (4xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 14, + "title": "Apdex Over Time (0.5s)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex trend. T=0.5s (satisfied), 4T=2.0s (tolerating). Below 0.85 = degraded experience.", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + }, + "color": { "mode": "continuous-GrYlRd" } + } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/dashboards.yaml b/infra/local/monitoring/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 00000000..2a76fbbd --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/dashboards.yaml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: weeth + orgId: 1 + folder: Weeth + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards + foldersFromFilesStructure: false diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/external-infra.json b/infra/local/monitoring/grafana/provisioning/dashboards/external-infra.json new file mode 100644 index 00000000..f38d9eb7 --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/external-infra.json @@ -0,0 +1,373 @@ +{ + "uid": "weeth-external-infra", + "title": "External Infrastructure", + "tags": ["weeth", "db", "redis"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "HikariCP (MySQL)", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "collapsed": false, + "panels": [] + }, + { + "id": 2, + "title": "Active", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 8 } + ] + } + } + } + }, + { + "id": 3, + "title": "Idle", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "green", "value": 3 } + ] + } + } + } + }, + { + "id": 4, + "title": "Pending", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 3 } + ] + } + } + } + }, + { + "id": 5, + "title": "Total / Max", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections", + "legendFormat": "total" + }, + { + "expr": "hikaricp_connections_max", + "legendFormat": "max" + } + ] + }, + { + "id": 6, + "title": "Timeout Total", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "커넥션 획득 timeout 누적 횟수. 0이 아니면 커넥션 풀 고갈 발생.", + "targets": [ + { + "expr": "hikaricp_connections_timeout_total", + "legendFormat": "timeouts" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 7, + "title": "Timeout Rate / min", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_timeout_total[1m]) * 60", + "legendFormat": "timeouts/min" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 0.1 } + ] + } + } + } + }, + { + "id": 8, + "title": "Connection Pool Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + }, + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + }, + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + }, + { + "expr": "hikaricp_connections", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "Connection Acquire Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_acquire_seconds_sum[1m]) / rate(hikaricp_connections_acquire_seconds_count[1m])", + "legendFormat": "avg acquire time" + }, + { + "expr": "hikaricp_connections_acquire_seconds_max", + "legendFormat": "max acquire time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "Connection Creation Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_creation_seconds_sum[1m]) / rate(hikaricp_connections_creation_seconds_count[1m])", + "legendFormat": "avg creation time" + }, + { + "expr": "hikaricp_connections_creation_seconds_max", + "legendFormat": "max creation time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Connection Usage Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_usage_seconds_sum[1m]) / rate(hikaricp_connections_usage_seconds_count[1m])", + "legendFormat": "avg usage time" + }, + { + "expr": "hikaricp_connections_usage_seconds_max", + "legendFormat": "max usage time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 12, + "title": "Redis", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 13 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Redis Command Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "commands/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 14, + "title": "Redis Command Latency", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(lettuce_command_completion_seconds_sum[1m]) / rate(lettuce_command_completion_seconds_count[1m])", + "legendFormat": "avg latency" + }, + { + "expr": "lettuce_command_completion_seconds_max", + "legendFormat": "max latency" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 15, + "title": "Redis Command by Type", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(command) (rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "{{command}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 16, + "title": "Redis Command Errors", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_firstresponse_seconds_count{outcome=\"ERROR\"}[1m]))", + "legendFormat": "errors/sec" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { "fillOpacity": 10 }, + "color": { "fixedColor": "red", "mode": "fixed" } + } + } + }, + { + "id": 17, + "title": "Redis Cache Hit Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Redis 서버 전체 캐시 히트율. redis_exporter의 keyspace_hits/misses 기반.", + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[5m]) / clamp_min(rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]), 0.001) * 100", + "legendFormat": "hit rate" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "green", "value": 80 } + ] + } + } + } + }, + { + "id": 18, + "title": "Redis Cache Hits / Misses", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[1m])", + "legendFormat": "hits/sec" + }, + { + "expr": "rate(redis_keyspace_misses_total[1m])", + "legendFormat": "misses/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/internal-infra.json b/infra/local/monitoring/grafana/provisioning/dashboards/internal-infra.json new file mode 100644 index 00000000..4395f758 --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/internal-infra.json @@ -0,0 +1,266 @@ +{ + "uid": "weeth-internal-infra", + "title": "Internal Infrastructure", + "tags": ["weeth", "jvm", "infra"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "Uptime", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_uptime_seconds", + "legendFormat": "uptime" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + } + }, + { + "id": 2, + "title": "Heap Usage %", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"}) / sum(jvm_memory_max_bytes{area=\"heap\"}) * 100", + "legendFormat": "heap %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 70 }, + { "color": "red", "value": 90 } + ] + } + } + } + }, + { + "id": 3, + "title": "Live Threads", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + } + ] + }, + { + "id": 4, + "title": "App CPU", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage * 100", + "legendFormat": "CPU %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 60 }, + { "color": "red", "value": 85 } + ] + } + } + } + }, + { + "id": 5, + "title": "JVM", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 }, + "collapsed": false, + "panels": [] + }, + { + "id": 6, + "title": "JVM Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"heap\"}", + "legendFormat": "{{id}} used" + }, + { + "expr": "jvm_memory_committed_bytes{area=\"heap\"}", + "legendFormat": "{{id}} committed" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 7, + "title": "JVM Heap Summary", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"})", + "legendFormat": "used" + }, + { + "expr": "sum(jvm_memory_committed_bytes{area=\"heap\"})", + "legendFormat": "committed" + }, + { + "expr": "sum(jvm_memory_max_bytes{area=\"heap\"})", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "JVM Non-Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"nonheap\"}", + "legendFormat": "{{id}} used" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "GC Pause Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_sum[1m])", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "GC Count / min", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_count[1m]) * 60", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Thread Count Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + }, + { + "expr": "jvm_threads_daemon_threads", + "legendFormat": "daemon" + }, + { + "expr": "jvm_threads_peak_threads", + "legendFormat": "peak" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 12, + "title": "Tomcat & System", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 3 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Tomcat Threads", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "tomcat_threads_current_threads", + "legendFormat": "current" + }, + { + "expr": "tomcat_threads_busy_threads", + "legendFormat": "busy" + }, + { + "expr": "tomcat_threads_config_max_threads", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 14, + "title": "CPU Usage", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage", + "legendFormat": "app CPU" + }, + { + "expr": "system_cpu_usage", + "legendFormat": "system CPU" + } + ], + "fieldConfig": { + "defaults": { "unit": "percentunit", "max": 1, "custom": { "fillOpacity": 10 } } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/logs-explorer.json b/infra/local/monitoring/grafana/provisioning/dashboards/logs-explorer.json new file mode 100644 index 00000000..9b7f8669 --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/logs-explorer.json @@ -0,0 +1,403 @@ +{ + "uid": "weeth-logs-explorer", + "title": "Logs Explorer", + "tags": [ + "weeth", + "logs" + ], + "timezone": "Asia/Seoul", + "refresh": "30s", + "time": { + "from": "now-30m", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "log_type", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "access", + "value": "access" + }, + { + "text": "audit", + "value": "audit" + }, + { + "text": "error", + "value": "error" + }, + { + "text": "application", + "value": "application" + } + ], + "includeAll": true, + "multi": false, + "query": "access,audit,error,application" + }, + { + "name": "level", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "ERROR", + "value": "ERROR" + }, + { + "text": "WARN", + "value": "WARN" + }, + { + "text": "INFO", + "value": "INFO" + }, + { + "text": "DEBUG", + "value": "DEBUG" + } + ], + "includeAll": true, + "multi": false, + "query": "ERROR,WARN,INFO,DEBUG" + }, + { + "name": "search", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Search" + }, + { + "name": "requestId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Request ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Log Volume", + "type": "timeseries", + "gridPos": { + "h": 5, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(level) (count_over_time({app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} [$__auto]))", + "legendFormat": "{{level}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "stacking": { + "mode": "normal" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "ERROR" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "WARN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "INFO" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DEBUG" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + } + }, + { + "id": 2, + "title": "Live Logs", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 41 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} | json | line_format \"{{.method}} {{.path}} {{.status}} {{.durationMs}}ms {{.message}}\" |= `$search` |= `$requestId`" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "showCommonLabels": false, + "wrapLogMessage": true, + "prettifyLogMessage": false, + "enableLogDetails": true, + "sortOrder": "Descending", + "dedupStrategy": "none" + } + }, + { + "id": 4, + "title": "Error Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 31 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"error\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 5, + "title": "Access Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 21 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 6, + "title": "Error Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"ERROR\"} [$__auto])) or vector(0)", + "legendFormat": "errors" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 7, + "title": "Warn Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"WARN\"} [$__auto])) or vector(0)", + "legendFormat": "warnings" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "yellow", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 8, + "title": "HTTP Status Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(status) (count_over_time({app=\"weeth\", log_type=\"access\"} | json [$__auto]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 9, + "title": "Slow Requests (> 1s)", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 55 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json | durationMs > 1000" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/trace-explorer.json b/infra/local/monitoring/grafana/provisioning/dashboards/trace-explorer.json new file mode 100644 index 00000000..8be83176 --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/trace-explorer.json @@ -0,0 +1,513 @@ +{ + "uid": "weeth-trace-explorer", + "title": "Trace Explorer", + "tags": [ + "weeth", + "trace" + ], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { + "from": "now-6h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "traceId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Trace ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "All Traces", + "description": "최근 전체 트레이스 목록입니다. actuator/health-check는 애플리케이션 observation 필터에서 제외합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{}", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 6, + "title": "Trace Search", + "description": "Tempo Explore에서 TraceQL로 검색합니다.", + "type": "text", + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 10 + }, + "options": { + "mode": "markdown", + "content": "TraceId를 선택하면 Tempo Explore에서 해당 TraceId를 바로 조회합니다.\n\n기본 조회 범위는 최근 6시간, 목록 제한은 200건입니다." + } + }, + { + "id": 4, + "title": "Error Traces", + "description": "에러가 발생한 트레이스를 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 14 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ status = error }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 3, + "title": "Slow Traces (> 500ms)", + "description": "500ms 이상 걸린 요청의 트레이스를 자동 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 24 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ duration > 500ms }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 5, + "title": "Related Logs (by Trace ID)", + "description": "Trace ID를 입력하면 해당 트레이스와 연관된 로그를 확인합니다.", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 34 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\"} |= `$traceId` | json", + "refId": "A" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/local/monitoring/grafana/provisioning/datasources/datasources.yaml b/infra/local/monitoring/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 00000000..f6118e0a --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/datasources/datasources.yaml @@ -0,0 +1,31 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + uid: prometheus + isDefault: true + editable: true + + - name: Tempo + type: tempo + access: proxy + url: http://tempo:3200 + uid: tempo + editable: true + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + uid: loki + editable: true + jsonData: + derivedFields: + - datasourceUid: tempo + matcherRegex: '"(?:traceId|trace_id|mdc_traceId|mdc_trace_id)"\s*:\s*"([^"]+)"' + name: traceId + url: "$${__value.raw}" + urlDisplayLabel: "View Trace" diff --git a/infra/local/monitoring/loki/loki-config.yaml b/infra/local/monitoring/loki/loki-config.yaml new file mode 100644 index 00000000..857590d3 --- /dev/null +++ b/infra/local/monitoring/loki/loki-config.yaml @@ -0,0 +1,41 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + ring: + kvstore: + store: inmemory + replication_factor: 1 + path_prefix: /loki + +schema_config: + configs: + - from: "2026-04-15" + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: loki_index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/index + cache_location: /loki/cache + filesystem: + directory: /loki/chunks + +limits_config: + retention_period: 3d + max_label_names_per_series: 5 + ingestion_rate_mb: 10 + ingestion_burst_size_mb: 20 + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + delete_request_store: filesystem diff --git a/infra/local/monitoring/prometheus/prometheus.yml b/infra/local/monitoring/prometheus/prometheus.yml new file mode 100644 index 00000000..1f861010 --- /dev/null +++ b/infra/local/monitoring/prometheus/prometheus.yml @@ -0,0 +1,18 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "weeth-app" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: ["host.docker.internal:8080"] + labels: + app: weeth + env: local + + - job_name: "redis" + static_configs: + - targets: ["redis-exporter:9121"] + labels: + env: local diff --git a/infra/local/monitoring/tempo/tempo-config.yaml b/infra/local/monitoring/tempo/tempo-config.yaml new file mode 100644 index 00000000..da5ddce8 --- /dev/null +++ b/infra/local/monitoring/tempo/tempo-config.yaml @@ -0,0 +1,32 @@ +stream_over_http_enabled: true + +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + +storage: + trace: + backend: local + wal: + path: /var/tempo/wal + local: + path: /var/tempo/blocks + +compactor: + compaction: + block_retention: 72h + +metrics_generator: + storage: + path: /var/tempo/generator/wal + registry: + external_labels: + source: tempo + traces_storage: + path: /var/tempo/generator/traces diff --git a/infra/prod/caddy/Caddyfile b/infra/prod/caddy/Caddyfile new file mode 100644 index 00000000..9572b864 --- /dev/null +++ b/infra/prod/caddy/Caddyfile @@ -0,0 +1,45 @@ +# {$DOMAIN} 은 Github Action에서 주입한다. +{$DOMAIN} { + + # 응답 압축 활성화 + # zstd: 최신 브라우저에서 더 효율적인 압축 + # gzip: 대부분 클라이언트와 호환되는 기본 압축 + encode zstd gzip + + # 보안 관련 HTTP 헤더 설정 + header { + + # HSTS (HTTP Strict Transport Security) + Strict-Transport-Security "max-age=31536000" + + # MIME 타입 스니핑 방지 + # 브라우저가 응답 타입을 추측하지 못하도록 하여 XSS 위험 감소 + X-Content-Type-Options "nosniff" + + # 클릭재킹 방지 + X-Frame-Options "DENY" + + # Referrer 정책 설정 + Referrer-Policy "strict-origin-when-cross-origin" + } + + # /actuator/** 외부 접근 차단 (Prometheus는 Docker 내부 네트워크로 직접 스크래핑) + handle /actuator/health { + import /etc/caddy/upstream.conf + } + handle /actuator { + respond 404 + } + handle /actuator/* { + respond 404 + } + + redir {$MONITORING_PATH} {$MONITORING_PATH}/ + + handle {$MONITORING_PATH}* { + reverse_proxy grafana:3000 + } + + # 실제 reverse_proxy 설정은 upstream.conf 파일에서 불러옴 + import /etc/caddy/upstream.conf +} diff --git a/infra/prod/caddy/upstream.conf b/infra/prod/caddy/upstream.conf new file mode 100644 index 00000000..7a77edec --- /dev/null +++ b/infra/prod/caddy/upstream.conf @@ -0,0 +1 @@ +reverse_proxy weeth-prod-app-blue:8080 diff --git a/infra/prod/docker-compose.yml b/infra/prod/docker-compose.yml new file mode 100644 index 00000000..e345a5bd --- /dev/null +++ b/infra/prod/docker-compose.yml @@ -0,0 +1,114 @@ +name: weeth-prod + +services: + caddy: + image: caddy:2.8 + container_name: weeth-prod-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./caddy/upstream.conf:/etc/caddy/upstream.conf + - caddy_data:/data + - caddy_config:/config + environment: + DOMAIN: ${DOMAIN} + MONITORING_PATH: ${MONITORING_PATH} + networks: + - web + + redis: + image: redis:7.0 + container_name: weeth-prod-redis + restart: unless-stopped + ports: + - "127.0.0.1:6379:6379" + volumes: + - redis_data:/data + networks: + - web + + app-blue: + image: ${APP_IMAGE} + container_name: weeth-prod-app-blue + restart: unless-stopped + profiles: ["blue"] + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: prod + TZ: Asia/Seoul + OTEL_JAVAAGENT_ENABLED: ${OTEL_JAVAAGENT_ENABLED:-true} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-weeth-server} + OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=prod} + OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} + OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} + OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-0.1} + OTEL_BSP_SCHEDULE_DELAY: ${OTEL_BSP_SCHEDULE_DELAY:-15000} + OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} + OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_EXPORTER_OTLP_COMPRESSION: ${OTEL_EXPORTER_OTLP_COMPRESSION:-gzip} + volumes: + - ${HOME}/keys:/app/keys:ro + ports: + - "127.0.0.1:18081:8080" + depends_on: + redis: + condition: service_started + logging: + driver: json-file + options: + max-size: "100m" + max-file: "5" + networks: + - web + + app-green: + image: ${APP_IMAGE} + container_name: weeth-prod-app-green + restart: unless-stopped + profiles: ["green"] + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: prod + TZ: Asia/Seoul + OTEL_JAVAAGENT_ENABLED: ${OTEL_JAVAAGENT_ENABLED:-true} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-weeth-server} + OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=prod} + OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} + OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} + OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-0.1} + OTEL_BSP_SCHEDULE_DELAY: ${OTEL_BSP_SCHEDULE_DELAY:-15000} + OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} + OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_EXPORTER_OTLP_COMPRESSION: ${OTEL_EXPORTER_OTLP_COMPRESSION:-gzip} + volumes: + - ${HOME}/keys:/app/keys:ro + ports: + - "127.0.0.1:18082:8080" + depends_on: + redis: + condition: service_started + logging: + driver: json-file + options: + max-size: "100m" + max-file: "5" + networks: + - web + +networks: + web: + driver: bridge + +volumes: + caddy_data: + caddy_config: + redis_data: diff --git a/infra/prod/monitoring/alloy/config.alloy b/infra/prod/monitoring/alloy/config.alloy new file mode 100644 index 00000000..2f80f6dd --- /dev/null +++ b/infra/prod/monitoring/alloy/config.alloy @@ -0,0 +1,58 @@ +discovery.docker "weeth" { + host = "unix:///var/run/docker.sock" + + filter { + name = "name" + values = ["weeth-prod-app"] + } +} + +loki.source.docker "app_logs" { + host = "unix:///var/run/docker.sock" + targets = discovery.docker.weeth.targets + forward_to = [loki.process.parse_json.receiver] +} + +loki.process "parse_json" { + stage.static_labels { + values = { app = "weeth", env = "prod" } + } + + stage.json { + expressions = { + level = "level", + logger_name = "logger_name", + } + } + + stage.labels { + values = { level = "", logger_name = "" } + } + + stage.match { + selector = "{logger_name=\"ACCESS_LOG\"}" + stage.static_labels { values = { log_type = "access" } } + } + stage.match { + selector = "{logger_name=\"AUDIT_LOG\"}" + stage.static_labels { values = { log_type = "audit" } } + } + stage.match { + selector = "{logger_name=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "error" } } + } + stage.match { + selector = "{logger_name!=\"ACCESS_LOG\", logger_name!=\"AUDIT_LOG\", logger_name!=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "application" } } + } + + stage.label_drop { values = ["logger_name"] } + + forward_to = [loki.write.default.receiver] +} + +loki.write "default" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} diff --git a/infra/prod/monitoring/docker-compose.yml b/infra/prod/monitoring/docker-compose.yml new file mode 100644 index 00000000..e2a26cd2 --- /dev/null +++ b/infra/prod/monitoring/docker-compose.yml @@ -0,0 +1,109 @@ +name: weeth-prod-monitoring + +services: + alloy: + image: grafana/alloy:v1.9.0 + group_add: + - "${DOCKER_GID}" + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - ./alloy/config.alloy:/etc/alloy/config.alloy:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + command: ["run", "--server.http.listen-addr=0.0.0.0:12345", "/etc/alloy/config.alloy"] + ports: + - "127.0.0.1:12345:12345" + depends_on: + - loki + networks: + - monitoring + - weeth-app + restart: unless-stopped + + loki: + image: grafana/loki:3.4.2 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - ./loki/loki-config.yaml:/etc/loki/loki-config.yaml:ro + - loki_data:/loki + command: ["-config.file=/etc/loki/loki-config.yaml", "-config.expand-env=true"] + ports: + - "127.0.0.1:3100:3100" + networks: + - monitoring + restart: unless-stopped + + redis-exporter: + image: oliver006/redis_exporter:v1.67.0 + environment: + REDIS_ADDR: redis:6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + networks: + - monitoring + - weeth-app + restart: unless-stopped + + prometheus: + image: prom/prometheus:v2.53.0 + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + ports: + - "127.0.0.1:9090:9090" + networks: + - monitoring + - weeth-app + restart: unless-stopped + + node-exporter: + image: prom/node-exporter:v1.9.0 + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - "--path.procfs=/host/proc" + - "--path.sysfs=/host/sys" + - "--path.rootfs=/rootfs" + - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)" + networks: + - monitoring + restart: unless-stopped + + grafana: + image: grafana/grafana:11.5.2 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-https://${DOMAIN}${MONITORING_PATH}/} + GF_SERVER_SERVE_FROM_SUB_PATH: "true" + GF_AUTH_ANONYMOUS_ENABLED: "false" + ports: + - "127.0.0.1:3000:3000" + depends_on: + - loki + - prometheus + networks: + - monitoring + - weeth-app + restart: unless-stopped + +networks: + monitoring: + driver: bridge + weeth-app: + external: true + name: weeth-prod_web + +volumes: + grafana_data: + loki_data: + prometheus_data: diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/api-overview.json b/infra/prod/monitoring/grafana/provisioning/dashboards/api-overview.json new file mode 100644 index 00000000..f7cf5807 --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/api-overview.json @@ -0,0 +1,388 @@ +{ + "uid": "weeth-api-overview", + "title": "API Overview", + "tags": ["weeth", "api"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "templating": { + "list": [ + { + "name": "uri", + "type": "query", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "query": "label_values(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\"}, uri)", + "includeAll": true, + "multi": true, + "current": { "text": "All", "value": "$__all" }, + "refresh": 2 + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Total Requests / min", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) * 60", + "legendFormat": "req/min" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqpm", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 100 }, + { "color": "red", "value": 500 } + ] + } + } + } + }, + { + "id": 2, + "title": "5xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "5xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + } + } + }, + { + "id": 3, + "title": "4xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "4xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 20 } + ] + } + } + } + }, + { + "id": 16, + "title": "Avg Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_sum{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)", + "legendFormat": "avg" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.3 }, + { "color": "red", "value": 0.5 } + ] + } + } + } + }, + { + "id": 4, + "title": "Apdex (0.5s)", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex score (사용자 체감 만족도). 1.0=최고, >0.94=훌륭함, >0.85=좋음, >0.7=나쁨, <0.7=매우 나쁨", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + } + } + } + }, + { + "id": 5, + "title": "P95 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P95" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 6, + "title": "P99 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 2 } + ] + } + } + } + }, + { + "id": 7, + "title": "Requests per Second", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "total" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"2..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "2xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "4xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "5xx" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "Response Time Distribution", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "엔드포인트별 P50/P95/P99 응답시간 추이. 기본(All)은 전체 엔드포인트별 분포를 표시하고, 상단 uri 필터에서 특정 엔드포인트를 선택하면 해당 API만 표시됩니다.", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P50" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P95" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 9, + "title": "HTTP Status Code Distribution", + "type": "piechart", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(status) (increase(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1h]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "overrides": [ + { "matcher": { "id": "byRegexp", "options": "^2\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^3\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^4\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^5\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] } + ] + } + }, + { + "id": 10, + "title": "Slowest Endpoints (P95)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\"}[5m])) by (le, uri)))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 11, + "title": "Top 10 Error Endpoints (5xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 12, + "title": "Request Rate by Endpoint", + "type": "timeseries", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(uri, method) (rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "{{method}} {{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 13, + "title": "Top 10 High-Traffic Endpoints (4xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 14, + "title": "Apdex Over Time (0.5s)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex trend. T=0.5s (satisfied), 4T=2.0s (tolerating). Below 0.85 = degraded experience.", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + }, + "color": { "mode": "continuous-GrYlRd" } + } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/dashboards.yaml b/infra/prod/monitoring/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 00000000..2a76fbbd --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/dashboards.yaml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: weeth + orgId: 1 + folder: Weeth + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards + foldersFromFilesStructure: false diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/external-infra.json b/infra/prod/monitoring/grafana/provisioning/dashboards/external-infra.json new file mode 100644 index 00000000..f38d9eb7 --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/external-infra.json @@ -0,0 +1,373 @@ +{ + "uid": "weeth-external-infra", + "title": "External Infrastructure", + "tags": ["weeth", "db", "redis"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "HikariCP (MySQL)", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "collapsed": false, + "panels": [] + }, + { + "id": 2, + "title": "Active", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 8 } + ] + } + } + } + }, + { + "id": 3, + "title": "Idle", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "green", "value": 3 } + ] + } + } + } + }, + { + "id": 4, + "title": "Pending", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 3 } + ] + } + } + } + }, + { + "id": 5, + "title": "Total / Max", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections", + "legendFormat": "total" + }, + { + "expr": "hikaricp_connections_max", + "legendFormat": "max" + } + ] + }, + { + "id": 6, + "title": "Timeout Total", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "커넥션 획득 timeout 누적 횟수. 0이 아니면 커넥션 풀 고갈 발생.", + "targets": [ + { + "expr": "hikaricp_connections_timeout_total", + "legendFormat": "timeouts" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 7, + "title": "Timeout Rate / min", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_timeout_total[1m]) * 60", + "legendFormat": "timeouts/min" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 0.1 } + ] + } + } + } + }, + { + "id": 8, + "title": "Connection Pool Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + }, + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + }, + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + }, + { + "expr": "hikaricp_connections", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "Connection Acquire Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_acquire_seconds_sum[1m]) / rate(hikaricp_connections_acquire_seconds_count[1m])", + "legendFormat": "avg acquire time" + }, + { + "expr": "hikaricp_connections_acquire_seconds_max", + "legendFormat": "max acquire time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "Connection Creation Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_creation_seconds_sum[1m]) / rate(hikaricp_connections_creation_seconds_count[1m])", + "legendFormat": "avg creation time" + }, + { + "expr": "hikaricp_connections_creation_seconds_max", + "legendFormat": "max creation time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Connection Usage Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_usage_seconds_sum[1m]) / rate(hikaricp_connections_usage_seconds_count[1m])", + "legendFormat": "avg usage time" + }, + { + "expr": "hikaricp_connections_usage_seconds_max", + "legendFormat": "max usage time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 12, + "title": "Redis", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 13 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Redis Command Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "commands/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 14, + "title": "Redis Command Latency", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(lettuce_command_completion_seconds_sum[1m]) / rate(lettuce_command_completion_seconds_count[1m])", + "legendFormat": "avg latency" + }, + { + "expr": "lettuce_command_completion_seconds_max", + "legendFormat": "max latency" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 15, + "title": "Redis Command by Type", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(command) (rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "{{command}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 16, + "title": "Redis Command Errors", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_firstresponse_seconds_count{outcome=\"ERROR\"}[1m]))", + "legendFormat": "errors/sec" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { "fillOpacity": 10 }, + "color": { "fixedColor": "red", "mode": "fixed" } + } + } + }, + { + "id": 17, + "title": "Redis Cache Hit Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Redis 서버 전체 캐시 히트율. redis_exporter의 keyspace_hits/misses 기반.", + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[5m]) / clamp_min(rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]), 0.001) * 100", + "legendFormat": "hit rate" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "green", "value": 80 } + ] + } + } + } + }, + { + "id": 18, + "title": "Redis Cache Hits / Misses", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[1m])", + "legendFormat": "hits/sec" + }, + { + "expr": "rate(redis_keyspace_misses_total[1m])", + "legendFormat": "misses/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/internal-infra.json b/infra/prod/monitoring/grafana/provisioning/dashboards/internal-infra.json new file mode 100644 index 00000000..50ed2b9e --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/internal-infra.json @@ -0,0 +1,334 @@ +{ + "uid": "weeth-internal-infra", + "title": "Internal Infrastructure", + "tags": ["weeth", "jvm", "infra"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "Uptime", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_uptime_seconds", + "legendFormat": "uptime" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + } + }, + { + "id": 2, + "title": "Heap Usage %", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"}) / sum(jvm_memory_max_bytes{area=\"heap\"}) * 100", + "legendFormat": "heap %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 70 }, + { "color": "red", "value": 90 } + ] + } + } + } + }, + { + "id": 3, + "title": "Live Threads", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + } + ] + }, + { + "id": 4, + "title": "App CPU", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage * 100", + "legendFormat": "CPU %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 60 }, + { "color": "red", "value": 85 } + ] + } + } + } + }, + { + "id": 5, + "title": "JVM", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 }, + "collapsed": false, + "panels": [] + }, + { + "id": 6, + "title": "JVM Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"heap\"}", + "legendFormat": "{{id}} used" + }, + { + "expr": "jvm_memory_committed_bytes{area=\"heap\"}", + "legendFormat": "{{id}} committed" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 7, + "title": "JVM Heap Summary", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"})", + "legendFormat": "used" + }, + { + "expr": "sum(jvm_memory_committed_bytes{area=\"heap\"})", + "legendFormat": "committed" + }, + { + "expr": "sum(jvm_memory_max_bytes{area=\"heap\"})", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "JVM Non-Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"nonheap\"}", + "legendFormat": "{{id}} used" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "GC Pause Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_sum[1m])", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "GC Count / min", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_count[1m]) * 60", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Thread Count Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + }, + { + "expr": "jvm_threads_daemon_threads", + "legendFormat": "daemon" + }, + { + "expr": "jvm_threads_peak_threads", + "legendFormat": "peak" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 12, + "title": "Tomcat & System", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 3 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Tomcat Threads", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "tomcat_threads_current_threads", + "legendFormat": "current" + }, + { + "expr": "tomcat_threads_busy_threads", + "legendFormat": "busy" + }, + { + "expr": "tomcat_threads_config_max_threads", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 14, + "title": "CPU Usage", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage", + "legendFormat": "app CPU" + }, + { + "expr": "system_cpu_usage", + "legendFormat": "system CPU" + } + ], + "fieldConfig": { + "defaults": { "unit": "percentunit", "max": 1, "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 15, + "title": "Host & Docker", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 37 }, + "collapsed": false, + "panels": [] + }, + { + "id": 16, + "title": "Host Memory Used / Total", + "type": "stat", + "gridPos": { "h": 4, "w": 12, "x": 0, "y": 38 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes", + "legendFormat": "used" + }, + { + "expr": "node_memory_MemTotal_bytes", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes" } + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "textMode": "value_and_name", + "wideLayout": true + } + }, + { + "id": 17, + "title": "Swap Used / Total", + "type": "stat", + "gridPos": { "h": 4, "w": 12, "x": 12, "y": 38 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes", + "legendFormat": "used" + }, + { + "expr": "node_memory_SwapTotal_bytes", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes" } + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "textMode": "value_and_name", + "wideLayout": true + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/logs-explorer.json b/infra/prod/monitoring/grafana/provisioning/dashboards/logs-explorer.json new file mode 100644 index 00000000..9b7f8669 --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/logs-explorer.json @@ -0,0 +1,403 @@ +{ + "uid": "weeth-logs-explorer", + "title": "Logs Explorer", + "tags": [ + "weeth", + "logs" + ], + "timezone": "Asia/Seoul", + "refresh": "30s", + "time": { + "from": "now-30m", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "log_type", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "access", + "value": "access" + }, + { + "text": "audit", + "value": "audit" + }, + { + "text": "error", + "value": "error" + }, + { + "text": "application", + "value": "application" + } + ], + "includeAll": true, + "multi": false, + "query": "access,audit,error,application" + }, + { + "name": "level", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "ERROR", + "value": "ERROR" + }, + { + "text": "WARN", + "value": "WARN" + }, + { + "text": "INFO", + "value": "INFO" + }, + { + "text": "DEBUG", + "value": "DEBUG" + } + ], + "includeAll": true, + "multi": false, + "query": "ERROR,WARN,INFO,DEBUG" + }, + { + "name": "search", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Search" + }, + { + "name": "requestId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Request ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Log Volume", + "type": "timeseries", + "gridPos": { + "h": 5, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(level) (count_over_time({app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} [$__auto]))", + "legendFormat": "{{level}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "stacking": { + "mode": "normal" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "ERROR" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "WARN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "INFO" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DEBUG" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + } + }, + { + "id": 2, + "title": "Live Logs", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 41 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} | json | line_format \"{{.method}} {{.path}} {{.status}} {{.durationMs}}ms {{.message}}\" |= `$search` |= `$requestId`" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "showCommonLabels": false, + "wrapLogMessage": true, + "prettifyLogMessage": false, + "enableLogDetails": true, + "sortOrder": "Descending", + "dedupStrategy": "none" + } + }, + { + "id": 4, + "title": "Error Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 31 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"error\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 5, + "title": "Access Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 21 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 6, + "title": "Error Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"ERROR\"} [$__auto])) or vector(0)", + "legendFormat": "errors" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 7, + "title": "Warn Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"WARN\"} [$__auto])) or vector(0)", + "legendFormat": "warnings" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "yellow", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 8, + "title": "HTTP Status Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(status) (count_over_time({app=\"weeth\", log_type=\"access\"} | json [$__auto]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 9, + "title": "Slow Requests (> 1s)", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 55 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json | durationMs > 1000" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/trace-explorer.json b/infra/prod/monitoring/grafana/provisioning/dashboards/trace-explorer.json new file mode 100644 index 00000000..8be83176 --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/trace-explorer.json @@ -0,0 +1,513 @@ +{ + "uid": "weeth-trace-explorer", + "title": "Trace Explorer", + "tags": [ + "weeth", + "trace" + ], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { + "from": "now-6h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "traceId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Trace ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "All Traces", + "description": "최근 전체 트레이스 목록입니다. actuator/health-check는 애플리케이션 observation 필터에서 제외합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{}", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 6, + "title": "Trace Search", + "description": "Tempo Explore에서 TraceQL로 검색합니다.", + "type": "text", + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 10 + }, + "options": { + "mode": "markdown", + "content": "TraceId를 선택하면 Tempo Explore에서 해당 TraceId를 바로 조회합니다.\n\n기본 조회 범위는 최근 6시간, 목록 제한은 200건입니다." + } + }, + { + "id": 4, + "title": "Error Traces", + "description": "에러가 발생한 트레이스를 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 14 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ status = error }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 3, + "title": "Slow Traces (> 500ms)", + "description": "500ms 이상 걸린 요청의 트레이스를 자동 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 24 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ duration > 500ms }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 5, + "title": "Related Logs (by Trace ID)", + "description": "Trace ID를 입력하면 해당 트레이스와 연관된 로그를 확인합니다.", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 34 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\"} |= `$traceId` | json", + "refId": "A" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/prod/monitoring/grafana/provisioning/datasources/datasources.yaml b/infra/prod/monitoring/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 00000000..8711dde7 --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/datasources/datasources.yaml @@ -0,0 +1,17 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + uid: prometheus + isDefault: true + editable: true + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + uid: loki + editable: true diff --git a/infra/prod/monitoring/loki/loki-config.yaml b/infra/prod/monitoring/loki/loki-config.yaml new file mode 100644 index 00000000..4dfe06ce --- /dev/null +++ b/infra/prod/monitoring/loki/loki-config.yaml @@ -0,0 +1,43 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + ring: + kvstore: + store: inmemory + replication_factor: 1 + path_prefix: /loki + +schema_config: + configs: + - from: "2026-04-15" + store: tsdb + object_store: s3 + schema: v13 + index: + prefix: loki_index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/index + cache_location: /loki/cache + aws: + s3: s3://${AWS_REGION}/${LOKI_S3_BUCKET} + s3forcepathstyle: false + +limits_config: + retention_period: 30d + max_label_names_per_series: 5 + max_label_value_length: 1024 + ingestion_rate_mb: 10 + ingestion_burst_size_mb: 20 + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + delete_request_store: filesystem diff --git a/infra/prod/monitoring/prometheus/prometheus.yml b/infra/prod/monitoring/prometheus/prometheus.yml new file mode 100644 index 00000000..9f16b399 --- /dev/null +++ b/infra/prod/monitoring/prometheus/prometheus.yml @@ -0,0 +1,36 @@ +global: + scrape_interval: 30s + evaluation_interval: 30s + +scrape_configs: + - job_name: "weeth-app" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: ["app-blue:8080", "app-green:8080"] + labels: + app: weeth + env: prod + + - job_name: "node-exporter" + static_configs: + - targets: ["node-exporter:9100"] + labels: + env: prod + + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + labels: + env: prod + + - job_name: "loki" + static_configs: + - targets: ["loki:3100"] + labels: + env: prod + + - job_name: "redis" + static_configs: + - targets: ["redis-exporter:9121"] + labels: + env: prod diff --git a/infra/prod/monitoring/scripts/deploy.sh b/infra/prod/monitoring/scripts/deploy.sh new file mode 100644 index 00000000..005a91a4 --- /dev/null +++ b/infra/prod/monitoring/scripts/deploy.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEPLOY_DIR="${DEPLOY_DIR:-/home/ubuntu/infra/prod/monitoring}" +APP_NETWORK="${APP_NETWORK:-weeth-prod_web}" +MONITORING_ENV_FILE="${MONITORING_ENV_FILE:-$DEPLOY_DIR/.env.monitoring}" + +cd "$DEPLOY_DIR" + +if [ ! -f "$MONITORING_ENV_FILE" ]; then + echo "[monitoring] env file not found: $MONITORING_ENV_FILE" + exit 1 +fi + +if [ -z "${DOCKER_GID:-}" ]; then + DOCKER_GID="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || stat -f '%g' /var/run/docker.sock 2>/dev/null || true)" +fi + +if [ -z "${DOCKER_GID:-}" ]; then + echo "[monitoring] failed to detect docker.sock group id" + echo "[monitoring] set DOCKER_GID explicitly in $MONITORING_ENV_FILE or the shell environment" + exit 1 +fi + +export MONITORING_ENV_FILE DOCKER_GID + +if ! docker network inspect "$APP_NETWORK" >/dev/null 2>&1; then + echo "[monitoring] required docker network not found: $APP_NETWORK" + echo "[monitoring] deploy the app stack first or create the network before deploying monitoring" + exit 1 +fi + +echo "[monitoring] docker.sock gid=$DOCKER_GID" +echo "[monitoring] pulling images..." +docker compose --env-file "$MONITORING_ENV_FILE" pull + +echo "[monitoring] starting monitoring stack..." +docker compose --env-file "$MONITORING_ENV_FILE" up -d + +echo "[monitoring] waiting for services to be healthy..." +for i in {1..30}; do + if curl -fsS "http://127.0.0.1:12345/-/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:9090/-/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3100/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3200/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3000/api/health" >/dev/null 2>&1; then + echo "[monitoring] all services healthy" + break + fi + + if [ "$i" -eq 30 ]; then + echo "[monitoring] health check failed — check docker compose logs" + exit 1 + fi + + sleep 2 +done + +echo "[monitoring] deploy completed" diff --git a/infra/prod/monitoring/tempo/tempo-config.yaml b/infra/prod/monitoring/tempo/tempo-config.yaml new file mode 100644 index 00000000..c6ef04c4 --- /dev/null +++ b/infra/prod/monitoring/tempo/tempo-config.yaml @@ -0,0 +1,38 @@ +stream_over_http_enabled: true + +server: + http_listen_port: 3200 + +distributor: + max_attribute_bytes: 1024 + receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + +query_frontend: + max_query_expression_size_bytes: 32768 + search: + default_spans_per_span_set: 1 + max_spans_per_span_set: 20 + +storage: + trace: + backend: s3 + s3: + bucket: ${TEMPO_S3_BUCKET} + endpoint: s3.${AWS_REGION}.amazonaws.com + wal: + path: /var/tempo/wal + local: + path: /var/tempo/blocks + +compactor: + compaction: + block_retention: 720h + +overrides: + defaults: + global: + max_bytes_per_trace: 3000000 diff --git a/infra/prod/scripts/deploy.sh b/infra/prod/scripts/deploy.sh new file mode 100755 index 00000000..bd623675 --- /dev/null +++ b/infra/prod/scripts/deploy.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Github Action에서 주입한 환경변수 사용 +: "${APP_IMAGE:?APP_IMAGE is required}" +: "${DOMAIN:?DOMAIN is required}" +: "${DEPLOY_DIR:=/opt/weeth/prod}" + +cd "$DEPLOY_DIR" + +export APP_IMAGE DOMAIN + +# EC2 홈 디렉토리의 .env를 심링크 +ln -sf "$HOME/.env" "$DEPLOY_DIR/.env" + +if [ ! -f ./caddy/upstream.conf ]; then + echo "reverse_proxy weeth-prod-app-blue:8080" > ./caddy/upstream.conf +fi + +if grep -q "app-blue" ./caddy/upstream.conf; then + NEW_COLOR="green" + NEW_HEALTH_PORT="18082" + OLD_COLOR="blue" +else + NEW_COLOR="blue" + NEW_HEALTH_PORT="18081" + OLD_COLOR="green" +fi + +echo "[deploy] image=$APP_IMAGE new_color=$NEW_COLOR old_color=$OLD_COLOR" + +docker compose --profile "$NEW_COLOR" -f docker-compose.yml pull "app-$NEW_COLOR" +docker compose --profile "$NEW_COLOR" -f docker-compose.yml up -d "app-$NEW_COLOR" + +for i in {1..20}; do + if curl -fsS "http://127.0.0.1:${NEW_HEALTH_PORT}/actuator/health" >/dev/null; then + echo "[deploy] new app is healthy" + break + fi + + if [ "$i" -eq 20 ]; then + echo "[deploy] health check failed" + exit 1 + fi + + sleep 3 +done + +echo "reverse_proxy weeth-prod-app-${NEW_COLOR}:8080" > ./caddy/upstream.conf + +# 현재 Caddy 컨테이너의 DOMAIN과 비교하여 변경 시에만 재생성 +CURRENT_DOMAIN=$(docker inspect weeth-prod-caddy --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep '^DOMAIN=' | cut -d= -f2-) + +if [ "$CURRENT_DOMAIN" != "$DOMAIN" ]; then + echo "[deploy] domain changed, recreating caddy" + docker compose up -d --force-recreate caddy +elif docker compose ps caddy --format '{{.State}}' 2>/dev/null | grep -q running; then + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile +else + docker compose up -d caddy +fi + +docker compose --profile "$OLD_COLOR" -f docker-compose.yml stop "app-$OLD_COLOR" || true +docker compose --profile "$OLD_COLOR" -f docker-compose.yml rm -f "app-$OLD_COLOR" || true + +docker image prune -f +echo "[deploy] completed" diff --git a/lombok.config b/lombok.config new file mode 100644 index 00000000..df71bb6a --- /dev/null +++ b/lombok.config @@ -0,0 +1,2 @@ +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true diff --git a/src/main/java/com/weeth/WeethApplication.java b/src/main/java/com/weeth/WeethApplication.java deleted file mode 100644 index 4f5c810f..00000000 --- a/src/main/java/com/weeth/WeethApplication.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.weeth; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; - -@EnableScheduling -@EnableJpaAuditing -@EnableWebSecurity -@SpringBootApplication -@ConfigurationPropertiesScan -public class WeethApplication { - - public static void main(String[] args) { - SpringApplication.run(WeethApplication.class, args); - } - -} diff --git a/src/main/java/com/weeth/domain/account/application/dto/AccountDTO.java b/src/main/java/com/weeth/domain/account/application/dto/AccountDTO.java deleted file mode 100644 index a9794584..00000000 --- a/src/main/java/com/weeth/domain/account/application/dto/AccountDTO.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.account.application.dto; - -import jakarta.validation.constraints.NotNull; - -import java.time.LocalDateTime; -import java.util.List; - -public class AccountDTO { - - public record Response( - Long accountId, - String description, - Integer totalAmount, - Integer currentAmount, - LocalDateTime time, - Integer cardinal, - List receipts - ) {} - - public record Save( - String description, - @NotNull Integer totalAmount, - @NotNull Integer cardinal - ) {} -} diff --git a/src/main/java/com/weeth/domain/account/application/dto/ReceiptDTO.java b/src/main/java/com/weeth/domain/account/application/dto/ReceiptDTO.java deleted file mode 100644 index 71ff3974..00000000 --- a/src/main/java/com/weeth/domain/account/application/dto/ReceiptDTO.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.weeth.domain.account.application.dto; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; - -import java.time.LocalDate; -import java.util.List; - -public class ReceiptDTO { - - public record Response( - Long id, - String description, - String source, - Integer amount, - LocalDate date, - List fileUrls - ) { - } - - public record Save( - String description, - String source, - @NotNull Integer amount, - @NotNull LocalDate date, - @NotNull Integer cardinal, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - public record Update( - String description, - String source, - @NotNull Integer amount, - @NotNull LocalDate date, - @NotNull Integer cardinal, - @Valid List<@NotNull FileSaveRequest> files - ) { - } -} diff --git a/src/main/java/com/weeth/domain/account/application/exception/AccountErrorCode.java b/src/main/java/com/weeth/domain/account/application/exception/AccountErrorCode.java deleted file mode 100644 index d694c551..00000000 --- a/src/main/java/com/weeth/domain/account/application/exception/AccountErrorCode.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.account.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum AccountErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 회비 장부 ID가 존재하지 않을 때 발생합니다.") - ACCOUNT_NOT_FOUND(2100, HttpStatus.NOT_FOUND, "존재하지 않는 장부입니다."), - - @ExplainError("이미 존재하는 장부를 중복 생성하려고 할 때 발생합니다.") - ACCOUNT_EXISTS(2101, HttpStatus.BAD_REQUEST, "이미 생성된 장부입니다."), - - @ExplainError("요청한 영수증 내역이 존재하지 않을 때 발생합니다.") - RECEIPT_NOT_FOUND(2102, HttpStatus.NOT_FOUND, "존재하지 않는 내역입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/account/application/exception/AccountExistsException.java b/src/main/java/com/weeth/domain/account/application/exception/AccountExistsException.java deleted file mode 100644 index 9e6ed8b5..00000000 --- a/src/main/java/com/weeth/domain/account/application/exception/AccountExistsException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.domain.account.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AccountExistsException extends BaseException { - public AccountExistsException() { - super(AccountErrorCode.ACCOUNT_EXISTS); - } -} - diff --git a/src/main/java/com/weeth/domain/account/application/exception/AccountNotFoundException.java b/src/main/java/com/weeth/domain/account/application/exception/AccountNotFoundException.java deleted file mode 100644 index 2e480f40..00000000 --- a/src/main/java/com/weeth/domain/account/application/exception/AccountNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.account.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AccountNotFoundException extends BaseException { - public AccountNotFoundException() { - super(AccountErrorCode.ACCOUNT_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/account/application/exception/ReceiptNotFoundException.java b/src/main/java/com/weeth/domain/account/application/exception/ReceiptNotFoundException.java deleted file mode 100644 index ac11d282..00000000 --- a/src/main/java/com/weeth/domain/account/application/exception/ReceiptNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.account.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class ReceiptNotFoundException extends BaseException { - public ReceiptNotFoundException() { - super(AccountErrorCode.RECEIPT_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/account/application/mapper/AccountMapper.java b/src/main/java/com/weeth/domain/account/application/mapper/AccountMapper.java deleted file mode 100644 index f428cc88..00000000 --- a/src/main/java/com/weeth/domain/account/application/mapper/AccountMapper.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.weeth.domain.account.application.mapper; - -import com.weeth.domain.account.application.dto.AccountDTO; -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.domain.entity.Account; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface AccountMapper { - - @Mapping(target = "accountId", source = "account.id") - @Mapping(target = "receipts", source = "receipts") - @Mapping(target = "time", source = "account.modifiedAt") - AccountDTO.Response to(Account account, List receipts); - - @Mapping(target = "currentAmount", source = "totalAmount") - Account from(AccountDTO.Save dto); -} diff --git a/src/main/java/com/weeth/domain/account/application/mapper/ReceiptMapper.java b/src/main/java/com/weeth/domain/account/application/mapper/ReceiptMapper.java deleted file mode 100644 index c2a8f8d7..00000000 --- a/src/main/java/com/weeth/domain/account/application/mapper/ReceiptMapper.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.account.application.mapper; - -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.domain.entity.Account; -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.file.application.dto.response.FileResponse; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.MappingConstants; -import org.mapstruct.ReportingPolicy; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface ReceiptMapper { - - List to(List account); - - ReceiptDTO.Response to(Receipt receipt, List fileUrls); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "description", source = "dto.description") - @Mapping(target = "account", source = "account") - Receipt from(ReceiptDTO.Save dto, Account account); -} diff --git a/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCase.java b/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCase.java deleted file mode 100644 index a9eb972b..00000000 --- a/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.account.application.usecase; - -import com.weeth.domain.account.application.dto.AccountDTO; - -public interface AccountUseCase { - AccountDTO.Response find(Integer cardinal); - - void save(AccountDTO.Save dto); -} diff --git a/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCaseImpl.java b/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCaseImpl.java deleted file mode 100644 index 179f0c09..00000000 --- a/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCaseImpl.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.weeth.domain.account.application.usecase; - -import com.weeth.domain.account.application.dto.AccountDTO; -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.application.exception.AccountExistsException; -import com.weeth.domain.account.application.mapper.AccountMapper; -import com.weeth.domain.account.application.mapper.ReceiptMapper; -import com.weeth.domain.account.domain.entity.Account; -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.account.domain.service.AccountGetService; -import com.weeth.domain.account.domain.service.AccountSaveService; -import com.weeth.domain.account.domain.service.ReceiptGetService; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.service.FileGetService; -import com.weeth.domain.user.domain.service.CardinalGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class AccountUseCaseImpl implements AccountUseCase { - - private final AccountGetService accountGetService; - private final AccountSaveService accountSaveService; - private final ReceiptGetService receiptGetService; - private final FileGetService fileGetService; - private final CardinalGetService cardinalGetService; - - private final AccountMapper accountMapper; - private final ReceiptMapper receiptMapper; - private final FileMapper fileMapper; - - @Override - public AccountDTO.Response find(Integer cardinal) { - Account account = accountGetService.find(cardinal); - List receipts = receiptGetService.findAllByAccountId(account.getId()); - List response = receipts.stream() - .map(receipt -> receiptMapper.to(receipt, getFiles(receipt.getId()))) - .toList(); - - return accountMapper.to(account, response); - } - - @Override - @Transactional - public void save(AccountDTO.Save dto) { - validate(dto); - cardinalGetService.findByAdminSide(dto.cardinal()); - - accountSaveService.save(accountMapper.from(dto)); - } - - private void validate(AccountDTO.Save dto) { - if (accountGetService.validate(dto.cardinal())) - throw new AccountExistsException(); - } - - private List getFiles(Long receiptId) { - return fileGetService.findAllByReceipt(receiptId).stream() - .map(fileMapper::toFileResponse) - .toList(); - } -} diff --git a/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCase.java b/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCase.java deleted file mode 100644 index 855a24a2..00000000 --- a/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCase.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.domain.account.application.usecase; - -import com.weeth.domain.account.application.dto.ReceiptDTO; - -public interface ReceiptUseCase { - void save(ReceiptDTO.Save dto); - - void update(Long receiptId, ReceiptDTO.Update dto); - - void delete(Long id); -} diff --git a/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCaseImpl.java b/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCaseImpl.java deleted file mode 100644 index f755585f..00000000 --- a/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCaseImpl.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.weeth.domain.account.application.usecase; - -import jakarta.transaction.Transactional; -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.application.mapper.ReceiptMapper; -import com.weeth.domain.account.domain.entity.Account; -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.account.domain.service.*; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.service.FileDeleteService; -import com.weeth.domain.file.domain.service.FileGetService; -import com.weeth.domain.file.domain.service.FileSaveService; -import com.weeth.domain.user.domain.service.CardinalGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class ReceiptUseCaseImpl implements ReceiptUseCase { - - private final ReceiptGetService receiptGetService; - private final ReceiptDeleteService receiptDeleteService; - private final ReceiptSaveService receiptSaveService; - private final ReceiptUpdateService receiptUpdateService; - private final AccountGetService accountGetService; - - private final FileGetService fileGetService; - private final FileSaveService fileSaveService; - private final FileDeleteService fileDeleteService; - - private final CardinalGetService cardinalGetService; - - private final ReceiptMapper mapper; - private final FileMapper fileMapper; - - - @Override - @Transactional - public void save(ReceiptDTO.Save dto) { - cardinalGetService.findByAdminSide(dto.cardinal()); - - Account account = accountGetService.find(dto.cardinal()); - Receipt receipt = receiptSaveService.save(mapper.from(dto, account)); - account.spend(receipt); - - List files = fileMapper.toFileList(dto.files(), receipt); - fileSaveService.save(files); - } - - @Override - @Transactional - public void update(Long receiptId, ReceiptDTO.Update dto){ - Account account = accountGetService.find(dto.cardinal()); - Receipt receipt = receiptGetService.find(receiptId); - account.cancel(receipt); - - if(!dto.files().isEmpty()){ // 업데이트하려는 파일이 있다면 파일을 전체 삭제한 뒤 저장 - List fileList = getFiles(receiptId); - fileDeleteService.delete(fileList); - - List files = fileMapper.toFileList(dto.files(), receipt); - fileSaveService.save(files); - } - receiptUpdateService.update(receipt, dto); - account.spend(receipt); - } - - private List getFiles(Long receiptId) { - return fileGetService.findAllByReceipt(receiptId); - } - - @Override - @Transactional - public void delete(Long id) { - Receipt receipt = receiptGetService.find(id); - List fileList = fileGetService.findAllByReceipt(id); - - receipt.getAccount().cancel(receipt); - - fileDeleteService.delete(fileList); - receiptDeleteService.delete(receipt); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/entity/Account.java b/src/main/java/com/weeth/domain/account/domain/entity/Account.java deleted file mode 100644 index 032749bb..00000000 --- a/src/main/java/com/weeth/domain/account/domain/entity/Account.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.weeth.domain.account.domain.entity; - -import jakarta.persistence.*; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -import java.util.ArrayList; -import java.util.List; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@SuperBuilder -public class Account extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "account_id") - private Long id; - - private String description; - - private Integer totalAmount; - - private Integer currentAmount; - - private Integer cardinal; - - @OneToMany(mappedBy = "account", cascade = CascadeType.REMOVE, orphanRemoval = true) - private List receipts = new ArrayList<>(); - - public void spend(Receipt receipt) { - this.receipts.add(receipt); - this.currentAmount -= receipt.getAmount(); - } - - public void cancel(Receipt receipt) { - this.receipts.remove(receipt); - this.currentAmount += receipt.getAmount(); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/entity/Receipt.java b/src/main/java/com/weeth/domain/account/domain/entity/Receipt.java deleted file mode 100644 index 83ea940e..00000000 --- a/src/main/java/com/weeth/domain/account/domain/entity/Receipt.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.weeth.domain.account.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -import java.time.LocalDate; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@SuperBuilder -public class Receipt extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "receipt_id") - private Long id; - - private String description; - - private String source; - - private Integer amount; - - private LocalDate date; - - @ManyToOne - @JoinColumn(name = "account_id") - private Account account; - - public void update(ReceiptDTO.Update dto){ - this.description = dto.description(); - this.source = dto.source(); - this.amount = dto.amount(); - this.date = dto.date(); - } - -} diff --git a/src/main/java/com/weeth/domain/account/domain/repository/AccountRepository.java b/src/main/java/com/weeth/domain/account/domain/repository/AccountRepository.java deleted file mode 100644 index 0599083f..00000000 --- a/src/main/java/com/weeth/domain/account/domain/repository/AccountRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.domain.account.domain.repository; - -import com.weeth.domain.account.domain.entity.Account; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface AccountRepository extends JpaRepository { - - Optional findByCardinal(Integer cardinal); - - boolean existsByCardinal(Integer cardinal); -} diff --git a/src/main/java/com/weeth/domain/account/domain/repository/ReceiptRepository.java b/src/main/java/com/weeth/domain/account/domain/repository/ReceiptRepository.java deleted file mode 100644 index 588a79ff..00000000 --- a/src/main/java/com/weeth/domain/account/domain/repository/ReceiptRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.domain.account.domain.repository; - -import com.weeth.domain.account.domain.entity.Receipt; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface ReceiptRepository extends JpaRepository { - List findAllByAccountIdOrderByCreatedAtDesc(Long accountId); -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/AccountGetService.java b/src/main/java/com/weeth/domain/account/domain/service/AccountGetService.java deleted file mode 100644 index bfc948f8..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/AccountGetService.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.domain.entity.Account; -import com.weeth.domain.account.domain.repository.AccountRepository; -import com.weeth.domain.account.application.exception.AccountNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AccountGetService { - - private final AccountRepository accountRepository; - - public Account find(Integer cardinal) { - return accountRepository.findByCardinal(cardinal) - .orElseThrow(AccountNotFoundException::new); - } - - public boolean validate(Integer cardinal) { - return accountRepository.existsByCardinal(cardinal); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/AccountSaveService.java b/src/main/java/com/weeth/domain/account/domain/service/AccountSaveService.java deleted file mode 100644 index d0bf2ccb..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/AccountSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.domain.entity.Account; -import com.weeth.domain.account.domain.repository.AccountRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AccountSaveService { - - private final AccountRepository accountRepository; - - public void save(Account account) { - accountRepository.save(account); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/ReceiptDeleteService.java b/src/main/java/com/weeth/domain/account/domain/service/ReceiptDeleteService.java deleted file mode 100644 index 7caca70e..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/ReceiptDeleteService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.account.domain.repository.ReceiptRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class ReceiptDeleteService { - - private final ReceiptRepository receiptRepository; - - public void delete(Receipt receipt) { - receiptRepository.delete(receipt); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/ReceiptGetService.java b/src/main/java/com/weeth/domain/account/domain/service/ReceiptGetService.java deleted file mode 100644 index 61312284..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/ReceiptGetService.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.account.domain.repository.ReceiptRepository; -import com.weeth.domain.account.application.exception.ReceiptNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class ReceiptGetService { - - private final ReceiptRepository receiptRepository; - - public Receipt find(Long id) { - return receiptRepository.findById(id) - .orElseThrow(ReceiptNotFoundException::new); - } - - public List findAllByAccountId(Long accountId) { - return receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(accountId); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/ReceiptSaveService.java b/src/main/java/com/weeth/domain/account/domain/service/ReceiptSaveService.java deleted file mode 100644 index 22a3933a..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/ReceiptSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.account.domain.repository.ReceiptRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class ReceiptSaveService { - - private final ReceiptRepository receiptRepository; - - public Receipt save(Receipt receipt) { - return receiptRepository.save(receipt); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/ReceiptUpdateService.java b/src/main/java/com/weeth/domain/account/domain/service/ReceiptUpdateService.java deleted file mode 100644 index 95462683..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/ReceiptUpdateService.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.domain.entity.Receipt; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class ReceiptUpdateService { - public void update(Receipt receipt, ReceiptDTO.Update dto) { - receipt.update(dto); - } -} \ No newline at end of file diff --git a/src/main/java/com/weeth/domain/account/presentation/AccountAdminController.java b/src/main/java/com/weeth/domain/account/presentation/AccountAdminController.java deleted file mode 100644 index bf1c565f..00000000 --- a/src/main/java/com/weeth/domain/account/presentation/AccountAdminController.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.weeth.domain.account.presentation; - -import com.weeth.domain.account.application.dto.AccountDTO; -import com.weeth.domain.account.application.exception.AccountErrorCode; -import com.weeth.domain.account.application.usecase.AccountUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_SAVE_SUCCESS; - -@Tag(name = "ACCOUNT ADMIN", description = "[ADMIN] 회비 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/account") -@ApiErrorCodeExample(AccountErrorCode.class) -public class AccountAdminController { - - private final AccountUseCase accountUseCase; - - @PostMapping - @Operation(summary="회비 총 금액 기입") - public CommonResponse save(@RequestBody @Valid AccountDTO.Save dto) { - accountUseCase.save(dto); - return CommonResponse.success(ACCOUNT_SAVE_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/account/presentation/AccountController.java b/src/main/java/com/weeth/domain/account/presentation/AccountController.java deleted file mode 100644 index 1cb72b9d..00000000 --- a/src/main/java/com/weeth/domain/account/presentation/AccountController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.weeth.domain.account.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.account.application.dto.AccountDTO; -import com.weeth.domain.account.application.exception.AccountErrorCode; -import com.weeth.domain.account.application.usecase.AccountUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_FIND_SUCCESS; -@Tag(name = "ACCOUNT", description = "회비 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/account") -@ApiErrorCodeExample(AccountErrorCode.class) -public class AccountController { - - private final AccountUseCase accountUseCase; - - @GetMapping("/{cardinal}") - @Operation(summary="회비 내역 조회") - public CommonResponse find(@PathVariable Integer cardinal) { - return CommonResponse.success(ACCOUNT_FIND_SUCCESS,accountUseCase.find(cardinal)); - } -} diff --git a/src/main/java/com/weeth/domain/account/presentation/AccountResponseCode.java b/src/main/java/com/weeth/domain/account/presentation/AccountResponseCode.java deleted file mode 100644 index 4d1bf484..00000000 --- a/src/main/java/com/weeth/domain/account/presentation/AccountResponseCode.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.weeth.domain.account.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum AccountResponseCode implements ResponseCodeInterface { - // AccountAdminController 관련 - ACCOUNT_SAVE_SUCCESS(1100, HttpStatus.OK, "회비가 성공적으로 저장되었습니다."), - - // AccountController 관련 - ACCOUNT_FIND_SUCCESS(1101, HttpStatus.OK, "회비가 성공적으로 조회되었습니다."), - - // ReceiptAdminController 관련 - RECEIPT_SAVE_SUCCESS(1102, HttpStatus.OK, "영수증이 성공적으로 저장되었습니다."), - RECEIPT_DELETE_SUCCESS(1103, HttpStatus.OK, "영수증이 성공적으로 삭제되었습니다."), - RECEIPT_UPDATE_SUCCESS(1104, HttpStatus.OK, "영수증이 성공적으로 업데이트 되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - AccountResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/java/com/weeth/domain/account/presentation/ReceiptAdminController.java b/src/main/java/com/weeth/domain/account/presentation/ReceiptAdminController.java deleted file mode 100644 index c9dfb434..00000000 --- a/src/main/java/com/weeth/domain/account/presentation/ReceiptAdminController.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.weeth.domain.account.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.application.exception.AccountErrorCode; -import com.weeth.domain.account.application.usecase.ReceiptUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; -import static com.weeth.domain.account.presentation.AccountResponseCode.*; - -@Tag(name = "RECEIPT ADMIN", description = "[ADMIN] 회비 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/receipts") -@ApiErrorCodeExample(AccountErrorCode.class) -public class ReceiptAdminController { - - private final ReceiptUseCase receiptUseCase; - - @PostMapping - @Operation(summary="회비 사용 내역 기입") - public CommonResponse save(@RequestBody @Valid ReceiptDTO.Save dto) { - receiptUseCase.save(dto); - return CommonResponse.success(RECEIPT_SAVE_SUCCESS); - } - - @DeleteMapping("/{receiptId}") - @Operation(summary="회비 사용 내역 취소") - public CommonResponse delete(@PathVariable Long receiptId) { - receiptUseCase.delete(receiptId); - return CommonResponse.success(RECEIPT_DELETE_SUCCESS); - } - - @PatchMapping("/{receiptId}") - @Operation(summary="회비 사용 내역 수정") - public CommonResponse update(@PathVariable Long receiptId, @RequestBody @Valid ReceiptDTO.Update dto) { - receiptUseCase.update(receiptId, dto); - return CommonResponse.success(RECEIPT_UPDATE_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/application/dto/AttendanceDTO.java b/src/main/java/com/weeth/domain/attendance/application/dto/AttendanceDTO.java deleted file mode 100644 index 072ea499..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/dto/AttendanceDTO.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.weeth.domain.attendance.application.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; -import com.weeth.domain.attendance.domain.entity.enums.Status; - -import java.time.LocalDateTime; -import java.util.List; - -public class AttendanceDTO { - - public record Main( - Integer attendanceRate, - String title, - Status status, - @Schema(description = "어드민인 경우 출석 코드 노출") - Integer code, - LocalDateTime start, - LocalDateTime end, - String location - ) {} - - public record Detail( - Integer attendanceCount, - Integer total, - Integer absenceCount, - List attendances - ) {} - - public record Response( - Long id, - Status status, - String title, - LocalDateTime start, - LocalDateTime end, - String location - ) {} - - public record CheckIn( - Integer code - ) {} - - public record AttendanceInfo( - Long id, - Status status, - String name, - String position, - String department, - String studentId - ) {} - - public record UpdateStatus( - @NotNull Long attendanceId, - @NotNull @Pattern(regexp = "ATTEND|ABSENT")String status - ) {} -} diff --git a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.java b/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.java deleted file mode 100644 index 2de9ba5f..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.attendance.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AttendanceCodeMismatchException extends BaseException { - public AttendanceCodeMismatchException() { - super(AttendanceErrorCode.ATTENDANCE_CODE_MISMATCH); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.java b/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.java deleted file mode 100644 index 3cd3f3b7..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.attendance.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum AttendanceErrorCode implements ErrorCodeInterface { - - @ExplainError("출석 정보를 찾을 수 없을 때 발생합니다.") - ATTENDANCE_NOT_FOUND(2200, HttpStatus.NOT_FOUND, "출석 정보가 존재하지 않습니다."), - - @ExplainError("입력한 출석 코드가 생성된 코드와 일치하지 않을 때 발생합니다.") - ATTENDANCE_CODE_MISMATCH(2201, HttpStatus.BAD_REQUEST, "출석 코드가 일치하지 않습니다."), - - @ExplainError("사용자가 출석 일정을 직접 수정하려고 시도할 때 발생합니다. (출석 로직 위반)") - ATTENDANCE_EVENT_TYPE_NOT_MATCH(2202, HttpStatus.BAD_REQUEST, "출석일정은 직접 수정할 수 없습니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.java b/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.java deleted file mode 100644 index 5c978a47..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.attendance.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AttendanceEventTypeNotMatchException extends BaseException { - public AttendanceEventTypeNotMatchException() { - super(AttendanceErrorCode.ATTENDANCE_EVENT_TYPE_NOT_MATCH); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.java b/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.java deleted file mode 100644 index c44b0b54..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.attendance.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AttendanceNotFoundException extends BaseException { - public AttendanceNotFoundException() { - super(AttendanceErrorCode.ATTENDANCE_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/application/mapper/AttendanceMapper.java b/src/main/java/com/weeth/domain/attendance/application/mapper/AttendanceMapper.java deleted file mode 100644 index 2b592858..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/mapper/AttendanceMapper.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.weeth.domain.attendance.application.mapper; - -import com.weeth.domain.attendance.application.dto.AttendanceDTO; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface AttendanceMapper { - - @Mappings({ - @Mapping(target = "attendanceRate", source = "user.attendanceRate"), - @Mapping(target = "title", source = "attendance.meeting.title"), - @Mapping(target = "status", source = "attendance.status"), - @Mapping(target = "code", ignore = true), - @Mapping(target = "start", source = "attendance.meeting.start"), - @Mapping(target = "end", source = "attendance.meeting.end"), - @Mapping(target = "location", source = "attendance.meeting.location"), - }) - AttendanceDTO.Main toMainDto(User user, Attendance attendance); - - @Mappings({ - @Mapping(target = "attendanceRate", source = "user.attendanceRate"), - @Mapping(target = "title", source = "attendance.meeting.title"), - @Mapping(target = "status", source = "attendance.status"), - @Mapping(target = "code", source = "attendance.meeting.code"), - @Mapping(target = "start", source = "attendance.meeting.start"), - @Mapping(target = "end", source = "attendance.meeting.end"), - @Mapping(target = "location", source = "attendance.meeting.location"), - }) - AttendanceDTO.Main toAdminResponse(User user, Attendance attendance); - - @Mappings({ - @Mapping(target = "attendances", source = "attendances"), - @Mapping(target = "total", expression = "java( user.getAttendanceCount() + user.getAbsenceCount() )") - }) - AttendanceDTO.Detail toDetailDto(User user, List attendances); - - @Mappings({ - @Mapping(target = "title", source = "attendance.meeting.title"), - @Mapping(target = "start", source = "attendance.meeting.start"), - @Mapping(target = "end", source = "attendance.meeting.end"), - @Mapping(target = "location", source = "attendance.meeting.location"), - }) AttendanceDTO.Response toResponseDto(Attendance attendance); - - @Mappings({ - @Mapping(target = "id", source = "attendance.id"), - @Mapping(target = "status", source = "attendance.status"), - @Mapping(target = "name", source = "attendance.user.name"), - @Mapping(target = "position", source = "attendance.user.position"), - @Mapping(target = "department", source = "attendance.user.department"), - @Mapping(target = "studentId", source = "attendance.user.studentId") - }) - AttendanceDTO.AttendanceInfo toAttendanceInfoDto(Attendance attendance); - -} diff --git a/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCase.java b/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCase.java deleted file mode 100644 index e6d87451..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCase.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.attendance.application.usecase; - -import java.util.List; -import com.weeth.domain.attendance.application.dto.AttendanceDTO; -import com.weeth.domain.attendance.application.dto.AttendanceDTO.AttendanceInfo; -import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException; - -import java.time.LocalDate; - -import static com.weeth.domain.attendance.application.dto.AttendanceDTO.Detail; -import static com.weeth.domain.attendance.application.dto.AttendanceDTO.Main; - -public interface AttendanceUseCase { - void checkIn(Long userId, Integer code) throws AttendanceCodeMismatchException; - - Main find(Long userId); - - Detail findAllDetailsByCurrentCardinal(Long userId); - - List findAllAttendanceByMeeting(Long meetingId); - - void close(LocalDate now, Integer cardinal); - - void updateAttendanceStatus(List attendanceUpdates); -} diff --git a/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImpl.java b/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImpl.java deleted file mode 100644 index 50c71581..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImpl.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.weeth.domain.attendance.application.usecase; - -import com.weeth.domain.attendance.application.dto.AttendanceDTO; -import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException; -import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException; -import com.weeth.domain.attendance.application.mapper.AttendanceMapper; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.entity.enums.Status; -import com.weeth.domain.attendance.domain.service.AttendanceGetService; -import com.weeth.domain.attendance.domain.service.AttendanceUpdateService; -import com.weeth.domain.schedule.application.exception.MeetingNotFoundException; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.service.MeetingGetService; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.service.UserCardinalGetService; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Comparator; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class AttendanceUseCaseImpl implements AttendanceUseCase { - - private final UserGetService userGetService; - private final UserCardinalGetService userCardinalGetService; - - private final AttendanceGetService attendanceGetService; - private final AttendanceUpdateService attendanceUpdateService; - private final AttendanceMapper mapper; - - private final MeetingGetService meetingGetService; - - @Override - @Transactional - public void checkIn(Long userId, Integer code) throws AttendanceCodeMismatchException { - User user = userGetService.find(userId); - - LocalDateTime now = LocalDateTime.now(); - Attendance todayMeeting = user.getAttendances().stream() - .filter(attendance -> attendance.getMeeting().getStart().minusMinutes(10).isBefore(now) - && attendance.getMeeting().getEnd().isAfter(now)) - .findAny() - .orElseThrow(AttendanceNotFoundException::new); - - if (todayMeeting.isWrong(code)) - throw new AttendanceCodeMismatchException(); - - if (todayMeeting.getStatus() != Status.ATTEND) - attendanceUpdateService.attend(todayMeeting); - } - - @Override - public AttendanceDTO.Main find(Long userId) { - User user = userGetService.find(userId); - - Attendance todayMeeting = user.getAttendances().stream() - .filter(attendance -> attendance.getMeeting().getStart().toLocalDate().isEqual(LocalDate.now()) - && attendance.getMeeting().getEnd().toLocalDate().isEqual(LocalDate.now())) - .findAny() - .orElse(null); - - if (Role.ADMIN == user.getRole()) { - return mapper.toAdminResponse(user, todayMeeting); - } - - return mapper.toMainDto(user, todayMeeting); - } - - public AttendanceDTO.Detail findAllDetailsByCurrentCardinal(Long userId) { - User user = userGetService.find(userId); - Cardinal currentCardinal = userCardinalGetService.getCurrentCardinal(user); - - List responses = user.getAttendances().stream() - .filter(attendance -> attendance.getMeeting().getCardinal().equals(currentCardinal.getCardinalNumber())) - .sorted(Comparator.comparing(attendance -> attendance.getMeeting().getStart())) - .map(mapper::toResponseDto) - .toList(); - - return mapper.toDetailDto(user, responses); - } - - @Override - public List findAllAttendanceByMeeting(Long meetingId) { - Meeting meeting = meetingGetService.find(meetingId); - - List attendances = attendanceGetService.findAllByMeeting(meeting); - - return attendances.stream() - .map(mapper::toAttendanceInfoDto) - .toList(); - } - - @Override - public void close(LocalDate now, Integer cardinal) { - List meetings = meetingGetService.find(cardinal); - - /* - todo 차후 리팩토링 정기모임 id를 입력받아서 해당 정기모임의 출석을 마감하도록 수정 - */ - Meeting targetMeeting = meetings.stream() - .filter(meeting -> meeting.getStart().toLocalDate().isEqual(now) - && meeting.getEnd().toLocalDate().isEqual(now)) - .findAny() - .orElseThrow(MeetingNotFoundException::new); - - List attendanceList = attendanceGetService.findAllByMeeting(targetMeeting); - - attendanceUpdateService.close(attendanceList); - } - - @Override - @Transactional - public void updateAttendanceStatus(List attendanceUpdates) { - attendanceUpdates.forEach(update -> { - Attendance attendance = attendanceGetService.findByAttendanceId(update.attendanceId()); - User user = attendance.getUser(); - - Status newStatus = Status.valueOf(update.status()); - - if (newStatus == Status.ABSENT) { - attendance.close(); - user.removeAttend(); - user.absent(); - } else { - attendance.attend(); - user.removeAbsent(); - user.attend(); - } - }); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/entity/Attendance.java b/src/main/java/com/weeth/domain/attendance/domain/entity/Attendance.java deleted file mode 100644 index 35197ad3..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/entity/Attendance.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.weeth.domain.attendance.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.attendance.domain.entity.enums.Status; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@SuperBuilder -public class Attendance extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "attendance_id") - private Long id; - - @Enumerated(EnumType.STRING) - private Status status; - - @ManyToOne - @JoinColumn(name = "meeting_id") - private Meeting meeting; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - @PrePersist - public void init() { - this.status = Status.PENDING; - } - - public Attendance(Meeting meeting, User user) { - this.meeting = meeting; - this.user = user; - } - - public void attend() { - this.status = Status.ATTEND; - } - - public void close() { - this.status = Status.ABSENT; - } - - public boolean isPending() { - return this.status == Status.PENDING; - } - - public boolean isWrong(Integer code) { - return !this.meeting.getCode().equals(code); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/entity/enums/Status.java b/src/main/java/com/weeth/domain/attendance/domain/entity/enums/Status.java deleted file mode 100644 index 4dbd7466..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/entity/enums/Status.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.attendance.domain.entity.enums; - -public enum Status { - ATTEND, - PENDING, - ABSENT -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/repository/AttendanceRepository.java b/src/main/java/com/weeth/domain/attendance/domain/repository/AttendanceRepository.java deleted file mode 100644 index 6fe213fa..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/repository/AttendanceRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.attendance.domain.repository; - -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.enums.Status; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; - -import java.util.List; - -public interface AttendanceRepository extends JpaRepository { - List findAllByMeetingAndUserStatus(Meeting meeting, Status status); - - @Modifying - @Query("DELETE FROM Attendance a WHERE a.meeting = :meeting") - void deleteAllByMeeting(Meeting meeting); -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.java b/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.java deleted file mode 100644 index 853c21ab..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.weeth.domain.attendance.domain.service; - -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.repository.AttendanceRepository; -import com.weeth.domain.schedule.domain.entity.Meeting; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class AttendanceDeleteService { - - private final AttendanceRepository attendanceRepository; - - public void deleteAll(Meeting meeting) { - attendanceRepository.deleteAllByMeeting(meeting); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceGetService.java b/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceGetService.java deleted file mode 100644 index 82c6d8c1..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceGetService.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.weeth.domain.attendance.domain.service; - -import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.repository.AttendanceRepository; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.enums.Status; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class AttendanceGetService { - - private final AttendanceRepository attendanceRepository; - - public List findAllByMeeting(Meeting meeting) { - return attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE); - } - public Attendance findByAttendanceId(Long attendanceId) { - return attendanceRepository.findById(attendanceId) - .orElseThrow(AttendanceNotFoundException::new); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceSaveService.java b/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceSaveService.java deleted file mode 100644 index 8ad82e6b..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceSaveService.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.weeth.domain.attendance.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.repository.AttendanceRepository; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class AttendanceSaveService { - - private final AttendanceRepository attendanceRepository; - - public void init(User user, List meetings) { - if (meetings != null) { - meetings.forEach(meeting -> { - Attendance attendance = attendanceRepository.save(new Attendance(meeting, user)); - user.add(attendance); - }); - } - } - - public void saveAll(List userList, Meeting meeting) { - List attendances = userList.stream() - .map(user -> new Attendance(meeting, user)) - .toList(); - - attendanceRepository.saveAll(attendances); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceScheduler.java b/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceScheduler.java deleted file mode 100644 index 57b541e3..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceScheduler.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.weeth.domain.attendance.domain.service; - -import jakarta.transaction.Transactional; -import java.util.List; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.service.MeetingGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AttendanceScheduler { - - private final MeetingGetService meetingGetService; - private final AttendanceGetService attendanceGetService; - private final AttendanceUpdateService attendanceUpdateService; - - @Transactional - @Scheduled(cron = "0 0 22 * * THU", zone = "Asia/Seoul") - public void autoCloseAttendance() { - List meetings = meetingGetService.findAllOpenMeetingsBeforeNow(); - - meetings.forEach(meeting -> { - meeting.close(); - List attendanceList = attendanceGetService.findAllByMeeting(meeting); - attendanceUpdateService.close(attendanceList); - }); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.java b/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.java deleted file mode 100644 index 4bfbeb6b..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.weeth.domain.attendance.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.entity.enums.Status; -import com.weeth.domain.user.domain.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@Transactional -@RequiredArgsConstructor -public class AttendanceUpdateService { - - public void attend(Attendance attendance) { - attendance.attend(); - attendance.getUser().attend(); - } - - public void close(List attendances) { - attendances.stream() - .filter(Attendance::isPending) - .forEach(attendance -> { - attendance.close(); - attendance.getUser().absent(); - }); - } - - public void updateUserAttendanceByStatus(List attendances) { - for (Attendance attendance : attendances) { - User user = attendance.getUser(); - if (attendance.getStatus().equals(Status.ATTEND)) { - user.removeAttend(); - } else { - user.removeAbsent(); - } - } - } -} diff --git a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceAdminController.java b/src/main/java/com/weeth/domain/attendance/presentation/AttendanceAdminController.java deleted file mode 100644 index 1d76a3c0..00000000 --- a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceAdminController.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.weeth.domain.attendance.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.attendance.application.dto.AttendanceDTO; -import com.weeth.domain.attendance.application.exception.AttendanceErrorCode; -import com.weeth.domain.attendance.application.usecase.AttendanceUseCase; -import com.weeth.domain.schedule.application.dto.MeetingDTO; -import com.weeth.domain.schedule.application.usecase.MeetingUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDate; -import java.util.List; - -import static com.weeth.domain.attendance.presentation.AttendanceResponseCode.*; - -@Tag(name = "ATTENDANCE ADMIN", description = "[ADMIN] 출석 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/attendances") -@ApiErrorCodeExample(AttendanceErrorCode.class) -public class AttendanceAdminController { - - private final AttendanceUseCase attendanceUseCase; - private final MeetingUseCase meetingUseCase; - - @PatchMapping - @Operation(summary="출석 마감") - public CommonResponse close(@RequestParam LocalDate now, @RequestParam Integer cardinal) { - attendanceUseCase.close(now, cardinal); - return CommonResponse.success(ATTENDANCE_CLOSE_SUCCESS); - } - - @GetMapping("/meetings") - @Operation(summary = "정기모임 조회") - public CommonResponse getMeetings(@RequestParam(required = false) Integer cardinal) { - MeetingDTO.Infos response = meetingUseCase.find(cardinal); - - return CommonResponse.success(MEETING_FIND_SUCCESS, response); - } - - @GetMapping("/{meetingId}") - @Operation(summary = "모든 인원 정기모임 출석 정보 조회") - public CommonResponse> getAllAttendance(@PathVariable Long meetingId) { - return CommonResponse.success(ATTENDANCE_FIND_DETAIL_SUCCESS, attendanceUseCase.findAllAttendanceByMeeting(meetingId)); - } - - @PatchMapping("/status") - @Operation(summary = "모든 인원 정기모임 개별 출석 상태 수정") - public CommonResponse updateAttendanceStatus(@RequestBody @Valid List attendanceUpdates) { - attendanceUseCase.updateAttendanceStatus(attendanceUpdates); - return CommonResponse.success(ATTENDANCE_UPDATED_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceController.java b/src/main/java/com/weeth/domain/attendance/presentation/AttendanceController.java deleted file mode 100644 index 356a2666..00000000 --- a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceController.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.weeth.domain.attendance.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.attendance.application.dto.AttendanceDTO; -import com.weeth.domain.attendance.application.exception.AttendanceErrorCode; -import com.weeth.domain.attendance.application.usecase.AttendanceUseCase; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.attendance.application.dto.AttendanceDTO.*; -import static com.weeth.domain.attendance.presentation.AttendanceResponseCode.ATTENDANCE_CHECKIN_SUCCESS; -import static com.weeth.domain.attendance.presentation.AttendanceResponseCode.ATTENDANCE_FIND_ALL_SUCCESS; -import static com.weeth.domain.attendance.presentation.AttendanceResponseCode.ATTENDANCE_FIND_SUCCESS; - -@Tag(name = "ATTENDANCE", description = "출석 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/attendances") -@ApiErrorCodeExample(AttendanceErrorCode.class) -public class AttendanceController { - - private final AttendanceUseCase attendanceUseCase; - - @PatchMapping - @Operation(summary="출석체크") - public CommonResponse checkIn(@Parameter(hidden = true) @CurrentUser Long userId, @RequestBody AttendanceDTO.CheckIn checkIn) throws AttendanceCodeMismatchException { - attendanceUseCase.checkIn(userId, checkIn.code()); - return CommonResponse.success(ATTENDANCE_CHECKIN_SUCCESS); - } - - @GetMapping - @Operation(summary="출석 메인페이지") - public CommonResponse
find(@Parameter(hidden = true) @CurrentUser Long userId) { - return CommonResponse.success(ATTENDANCE_FIND_SUCCESS, attendanceUseCase.find(userId)); - } - - @GetMapping("/detail") - @Operation(summary="출석 내역 상세조회") - public CommonResponse findAll(@Parameter(hidden = true) @CurrentUser Long userId) { - return CommonResponse.success(ATTENDANCE_FIND_ALL_SUCCESS, attendanceUseCase.findAllDetailsByCurrentCardinal(userId)); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceResponseCode.java b/src/main/java/com/weeth/domain/attendance/presentation/AttendanceResponseCode.java deleted file mode 100644 index d3dd0068..00000000 --- a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceResponseCode.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.weeth.domain.attendance.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum AttendanceResponseCode implements ResponseCodeInterface { - //AttendanceAdminController 관련 - ATTENDANCE_CLOSE_SUCCESS(1200, HttpStatus.OK, "출석이 성공적으로 마감되었습니다."), - ATTENDANCE_UPDATED_SUCCESS(1201, HttpStatus.OK, "개별 출석 상태가 성공적으로 수정되었습니다."), - ATTENDANCE_FIND_DETAIL_SUCCESS(1202, HttpStatus.OK, "모든 인원의 정기모임 출석 정보가 성공적으로 조회되었습니다."), - MEETING_FIND_SUCCESS(1203, HttpStatus.OK, "기수별 정기모임 리스트를 성공적으로 조회했습니다."), - - //AttendanceController 관련 - ATTENDANCE_CHECKIN_SUCCESS(1204, HttpStatus.OK, "출석이 성공적으로 처리되었습니다."), - ATTENDANCE_FIND_SUCCESS(1205, HttpStatus.OK, "사용자의 출석 정보가 성공적으로 조회되었습니다."), - ATTENDANCE_FIND_ALL_SUCCESS(1206, HttpStatus.OK, "사용자의 상세 출석 정보가 성공적으로 조회되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - AttendanceResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java b/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java deleted file mode 100644 index 0d01cdad..00000000 --- a/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.weeth.domain.board.application.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public class NoticeDTO { - - @Builder - public record Save( - @NotNull String title, - @NotNull String content, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record Update( - @NotNull String title, - @NotNull String content, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record Response( - Long id, - String name, - Position position, - Role role, - String title, - String content, - LocalDateTime time, //createdAt - Integer commentCount, - List comments, - List fileUrls - ) { - } - - @Builder - public record ResponseAll( - Long id, - String name, - Position position, - Role role, - String title, - String content, - LocalDateTime time,//modifiedAt - Integer commentCount, - boolean hasFile - ) { - } - - @Builder - public record SaveResponse( - @Schema(description = "공지사항 생성 응답", example = "1") - long id - ) { - } - -} diff --git a/src/main/java/com/weeth/domain/board/application/dto/PartPostDTO.java b/src/main/java/com/weeth/domain/board/application/dto/PartPostDTO.java deleted file mode 100644 index 72c2680c..00000000 --- a/src/main/java/com/weeth/domain/board/application/dto/PartPostDTO.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.board.application.dto; - -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; - -public record PartPostDTO( - @NotNull Part part, - @NotNull Category category, - Integer cardinalNumber, - Integer week, - String studyName -) { -} diff --git a/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java b/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java deleted file mode 100644 index 885d6905..00000000 --- a/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.weeth.domain.board.application.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public class PostDTO { - - @Builder - public record Save( - @NotBlank(message = "제목 입력은 필수입니다.") String title, - @NotBlank(message = "내용 입력은 필수입니다.") String content, - @NotNull Category category, - String studyName, - int week, - @NotNull Part part, - @NotNull Integer cardinalNumber, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record SaveEducation( - @NotNull String title, - @NotNull String content, - @NotNull List parts, - @NotNull Integer cardinalNumber, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record SaveResponse( - @Schema(description = "게시글 생성시 응답", example = "1") - long id - ) { - } - - @Builder - public record Update( - String title, - String content, - String studyName, - Integer week, - Part part, - Integer cardinalNumber, - @Valid List files - ) { - } - - @Builder - public record UpdateEducation( - String title, - String content, - List parts, - Integer cardinalNumber, - @Valid List files - ) { - } - - @Builder - public record Response( - Long id, - String name, - Position position, - Role role, - String title, - String content, - String studyName, - Integer week, - Integer cardinalNumber, - Part part, - List parts, - LocalDateTime time, - Integer commentCount, - List comments, - List fileUrls - ) { - } - - @Builder - public record ResponseAll( - Long id, - String name, - Part part, - Position position, - Role role, - String title, - String content, - String studyName, - int week, - LocalDateTime time, - Integer commentCount, - boolean hasFile, - boolean isNew - ) { - } - - @Builder - public record ResponseEducationAll( - Long id, - String name, - List parts, - Position position, - Role role, - String title, - String content, - LocalDateTime time, - Integer commentCount, - boolean hasFile, - boolean isNew - ) { - } - - public record ResponseStudyNames( - List studyNames - ) { - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/BoardErrorCode.java b/src/main/java/com/weeth/domain/board/application/exception/BoardErrorCode.java deleted file mode 100644 index d6a1851d..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/BoardErrorCode.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum BoardErrorCode implements ErrorCodeInterface { - - @ExplainError("검색 조건에 맞는 게시글이 하나도 없을 때 발생합니다.") - NO_SEARCH_RESULT(2300, HttpStatus.NOT_FOUND, "일치하는 검색 결과를 찾을 수 없습니다."), - - @ExplainError("요청한 페이지 번호가 유효 범위를 벗어났을 때 발생합니다.") - PAGE_NOT_FOUND(2301, HttpStatus.NOT_FOUND, "존재하지 않는 페이지입니다."), - - @ExplainError("일반 유저가 어드민 전용 카테고리에 접근하려 할 때 발생합니다.") - CATEGORY_ACCESS_DENIED(2302, HttpStatus.FORBIDDEN, "어드민 유저만 접근 가능한 카테고리입니다"); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.java b/src/main/java/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.java deleted file mode 100644 index 3e67ae51..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class CategoryAccessDeniedException extends BaseException { - public CategoryAccessDeniedException() { - super(BoardErrorCode.CATEGORY_ACCESS_DENIED); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoSearchResultException.java b/src/main/java/com/weeth/domain/board/application/exception/NoSearchResultException.java deleted file mode 100644 index 475d7216..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoSearchResultException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class NoSearchResultException extends BaseException { - public NoSearchResultException() { - super(BoardErrorCode.NO_SEARCH_RESULT); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoticeErrorCode.java b/src/main/java/com/weeth/domain/board/application/exception/NoticeErrorCode.java deleted file mode 100644 index 06b62f98..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoticeErrorCode.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum NoticeErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 공지사항 ID에 해당하는 공지사항이 없을 때 발생합니다.") - NOTICE_NOT_FOUND(2303, HttpStatus.NOT_FOUND, "존재하지 않는 공지사항입니다."), - - @ExplainError("일반 게시판에서 공지사항을 수정하려 하거나, 그 반대의 경우 발생합니다.") - NOTICE_TYPE_NOT_MATCH(2304, HttpStatus.BAD_REQUEST, "공지사항은 공지사항 게시판에서 수정하세요."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoticeNotFoundException.java b/src/main/java/com/weeth/domain/board/application/exception/NoticeNotFoundException.java deleted file mode 100644 index b42fb2d7..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoticeNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class NoticeNotFoundException extends BaseException { - public NoticeNotFoundException() { - super(NoticeErrorCode.NOTICE_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoticeTypeNotMatchException.java b/src/main/java/com/weeth/domain/board/application/exception/NoticeTypeNotMatchException.java deleted file mode 100644 index 51a51bb8..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoticeTypeNotMatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class NoticeTypeNotMatchException extends BaseException { - public NoticeTypeNotMatchException() { - super(NoticeErrorCode.NOTICE_TYPE_NOT_MATCH); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/PageNotFoundException.java b/src/main/java/com/weeth/domain/board/application/exception/PageNotFoundException.java deleted file mode 100644 index 7bcf73e7..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/PageNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class PageNotFoundException extends BaseException { - public PageNotFoundException() { - super(BoardErrorCode.PAGE_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/PostErrorCode.java b/src/main/java/com/weeth/domain/board/application/exception/PostErrorCode.java deleted file mode 100644 index 681dbb89..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/PostErrorCode.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum PostErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 게시글 ID에 해당하는 게시글이 없을 때 발생합니다.") - POST_NOT_FOUND(2305, HttpStatus.NOT_FOUND, "존재하지 않는 게시물입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/PostNotFoundException.java b/src/main/java/com/weeth/domain/board/application/exception/PostNotFoundException.java deleted file mode 100644 index 27e140b4..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/PostNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class PostNotFoundException extends BaseException { - public PostNotFoundException() { - super(PostErrorCode.POST_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java b/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java deleted file mode 100644 index 413a126d..00000000 --- a/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.weeth.domain.board.application.mapper; - -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = CommentMapper.class, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface NoticeMapper { - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "user", source = "user") - }) - Notice fromNoticeDto(NoticeDTO.Save dto, User user); - - @Mappings({ - @Mapping(target = "name", source = "notice.user.name"), - @Mapping(target = "position", source = "notice.user.position"), - @Mapping(target = "role", source = "notice.user.role"), - @Mapping(target = "time", source = "notice.createdAt"), - @Mapping(target = "hasFile", expression = "java(fileExists)") - }) - NoticeDTO.ResponseAll toAll(Notice notice, boolean fileExists); - - @Mappings({ - @Mapping(target = "name", source = "notice.user.name"), - @Mapping(target = "position", source = "notice.user.position"), - @Mapping(target = "role", source = "notice.user.role"), - @Mapping(target = "time", source = "notice.createdAt"), - @Mapping(target = "comments", source = "comments") - }) - NoticeDTO.Response toNoticeDto(Notice notice, List fileUrls, List comments); - - NoticeDTO.SaveResponse toSaveResponse(Notice notice); - -} diff --git a/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java b/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java deleted file mode 100644 index 45aa58f0..00000000 --- a/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.weeth.domain.board.application.mapper; - -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = CommentMapper.class, unmappedTargetPolicy = ReportingPolicy.IGNORE, imports = { java.time.LocalDateTime.class }) -public interface PostMapper { - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "createdAt", ignore = true), - @Mapping(target = "modifiedAt", ignore = true), - @Mapping(target = "user", source = "user"), - @Mapping(target = "part", source = "dto.part"), - @Mapping(target = "parts", expression = "java(List.of(dto.part()))"), - @Mapping(target = "cardinalNumber", source = "dto.cardinalNumber") - }) - Post fromPostDto(PostDTO.Save dto, User user); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "createdAt", ignore = true) - @Mapping(target = "modifiedAt", ignore = true) - @Mapping(target = "user", source = "user") - @Mapping(target = "part", ignore = true) - @Mapping(target = "parts", source = "dto.parts") - @Mapping(target = "cardinalNumber", source = "dto.cardinalNumber") - @Mapping(target = "category", constant = "Education") - Post fromEducationDto(PostDTO.SaveEducation dto, User user); - - PostDTO.SaveResponse toSaveResponse(Post post); - - @Mappings({ - @Mapping(target = "name", source = "post.user.name"), - @Mapping(target = "position", source = "post.user.position"), - @Mapping(target = "role", source = "post.user.role"), - @Mapping(target = "time", source = "post.createdAt"), - @Mapping(target = "hasFile", expression = "java(fileExists)"), - @Mapping(target = "isNew", expression = "java(post.getCreatedAt().isAfter(LocalDateTime.now().minusHours(24)))") - }) - PostDTO.ResponseAll toAll(Post post, boolean fileExists); - - @Mappings({ - @Mapping(target = "id", source = "post.id"), - @Mapping(target = "name", source = "post.user.name"), - @Mapping(target = "parts", source = "post.parts"), - @Mapping(target = "position", source = "post.user.position"), - @Mapping(target = "role", source = "post.user.role"), - @Mapping(target = "commentCount", source = "post.commentCount"), - @Mapping(target = "time", source = "post.createdAt"), - @Mapping(target = "hasFile", expression = "java(fileExists)"), - @Mapping(target = "isNew", expression = "java(post.getCreatedAt().isAfter(LocalDateTime.now().minusHours(24)))") - }) - PostDTO.ResponseEducationAll toEducationAll(Post post, boolean fileExists); - - @Mappings({ - @Mapping(target = "name", source = "post.user.name"), - @Mapping(target = "position", source = "post.user.position"), - @Mapping(target = "role", source = "post.user.role"), - @Mapping(target = "time", source = "post.createdAt"), - @Mapping(target = "comments", source = "comments") - }) - PostDTO.Response toPostDto(Post post, List fileUrls, List comments); - - default PostDTO.ResponseStudyNames toStudyNames(List studyNames) { - return new PostDTO.ResponseStudyNames(studyNames); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecase.java b/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecase.java deleted file mode 100644 index b2dc0d40..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecase.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import org.springframework.data.domain.Slice; - - -public interface NoticeUsecase { - NoticeDTO.SaveResponse save(NoticeDTO.Save dto, Long userId); - - NoticeDTO.Response findNotice(Long noticeId); - - Slice findNotices(int pageNumber, int pageSize); - - NoticeDTO.SaveResponse update(Long noticeId, NoticeDTO.Update dto, Long userId) throws UserNotMatchException; - - void delete(Long noticeId, Long userId) throws UserNotMatchException; - - Slice searchNotice(String keyword, int pageNumber, int pageSize); -} diff --git a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java b/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java deleted file mode 100644 index a9d2fce4..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java +++ /dev/null @@ -1,179 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.exception.NoSearchResultException; -import com.weeth.domain.board.application.exception.PageNotFoundException; -import com.weeth.domain.board.application.mapper.NoticeMapper; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.service.NoticeDeleteService; -import com.weeth.domain.board.domain.service.NoticeFindService; -import com.weeth.domain.board.domain.service.NoticeSaveService; -import com.weeth.domain.board.domain.service.NoticeUpdateService; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.service.FileDeleteService; -import com.weeth.domain.file.domain.service.FileGetService; -import com.weeth.domain.file.domain.service.FileSaveService; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class NoticeUsecaseImpl implements NoticeUsecase { - - private final NoticeSaveService noticeSaveService; - private final NoticeFindService noticeFindService; - private final NoticeUpdateService noticeUpdateService; - private final NoticeDeleteService noticeDeleteService; - - private final UserGetService userGetService; - - private final FileSaveService fileSaveService; - private final FileGetService fileGetService; - private final FileDeleteService fileDeleteService; - - private final NoticeMapper mapper; - private final CommentMapper commentMapper; - private final FileMapper fileMapper; - - @Override - @Transactional - public NoticeDTO.SaveResponse save(NoticeDTO.Save request, Long userId) { - User user = userGetService.find(userId); - - Notice notice = mapper.fromNoticeDto(request, user); - Notice savedNotice = noticeSaveService.save(notice); - - List files = fileMapper.toFileList(request.files(), notice); - fileSaveService.save(files); - - return mapper.toSaveResponse(savedNotice); - } - - @Override - public NoticeDTO.Response findNotice(Long noticeId) { - Notice notice = noticeFindService.find(noticeId); - - List response = getFiles(noticeId).stream() - .map(fileMapper::toFileResponse) - .toList(); - - return mapper.toNoticeDto(notice, response, filterParentComments(notice.getComments())); - } - - @Override - public Slice findNotices(int pageNumber, int pageSize) { - if (pageNumber < 0) { - throw new PageNotFoundException(); - } - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); // id를 기준으로 내림차순 - Slice notices = noticeFindService.findRecentNotices(pageable); - return notices.map(notice->mapper.toAll(notice, checkFileExistsByNotice(notice.id))); - } - - @Override - public Slice searchNotice(String keyword, int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - keyword = keyword.strip(); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice notices = noticeFindService.search(keyword, pageable); - - if (notices.isEmpty()){ - throw new NoSearchResultException(); - } - - return notices.map(notice -> mapper.toAll(notice, checkFileExistsByNotice(notice.id))); - } - - @Override - @Transactional - public NoticeDTO.SaveResponse update(Long noticeId, NoticeDTO.Update dto, Long userId) { - Notice notice = validateOwner(noticeId, userId); - - List fileList = getFiles(noticeId); - fileDeleteService.delete(fileList); - - List files = fileMapper.toFileList(dto.files(), notice); - fileSaveService.save(files); - - noticeUpdateService.update(notice, dto); - - return mapper.toSaveResponse(notice); - } - - @Override - @Transactional - public void delete(Long noticeId, Long userId) { - validateOwner(noticeId, userId); - - List fileList = getFiles(noticeId); - fileDeleteService.delete(fileList); - - noticeDeleteService.delete(noticeId); - } - - private List getFiles(Long noticeId) { - return fileGetService.findAllByNotice(noticeId); - } - - private Notice validateOwner(Long noticeId, Long userId) { - Notice notice = noticeFindService.find(noticeId); - if (!notice.getUser().getId().equals(userId)) { - throw new UserNotMatchException(); - } - return notice; - } - - private boolean checkFileExistsByNotice(Long noticeId){ - return !fileGetService.findAllByNotice(noticeId).isEmpty(); - } - - private List filterParentComments(List comments) { - Map> commentMap = comments.stream() - .filter(comment -> comment.getParent() != null) - .collect(Collectors.groupingBy(comment -> comment.getParent().getId())); - - return comments.stream() - .filter(comment -> comment.getParent() == null) // 부모 댓글만 가져오기 - .map(parent -> mapToDtoWithChildren(parent, commentMap)) - .toList(); - } - - private CommentDTO.Response mapToDtoWithChildren(Comment comment, Map> commentMap) { - List children = commentMap.getOrDefault(comment.getId(), Collections.emptyList()) - .stream() - .map(child -> mapToDtoWithChildren(child, commentMap)) - .collect(Collectors.toList()); - - List files = fileGetService.findAllByComment(comment.getId()).stream() - .map(fileMapper::toFileResponse) - .toList(); - - return commentMapper.toCommentDto(comment, children, files); - } - - private void validatePageNumber(int pageNumber){ - if (pageNumber < 0) { - throw new PageNotFoundException(); - } - } -} diff --git a/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java b/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java deleted file mode 100644 index ab8016db..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java +++ /dev/null @@ -1,288 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.PartPostDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.application.exception.CategoryAccessDeniedException; -import com.weeth.domain.board.application.exception.NoSearchResultException; -import com.weeth.domain.board.application.exception.PageNotFoundException; -import com.weeth.domain.board.application.mapper.PostMapper; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.board.domain.service.PostDeleteService; -import com.weeth.domain.board.domain.service.PostFindService; -import com.weeth.domain.board.domain.service.PostSaveService; -import com.weeth.domain.board.domain.service.PostUpdateService; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.service.FileDeleteService; -import com.weeth.domain.file.domain.service.FileGetService; -import com.weeth.domain.file.domain.service.FileSaveService; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.service.CardinalGetService; -import com.weeth.domain.user.domain.service.UserCardinalGetService; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.*; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class PostUseCaseImpl implements PostUsecase { - - private final PostSaveService postSaveService; - private final PostFindService postFindService; - private final PostUpdateService postUpdateService; - private final PostDeleteService postDeleteService; - - private final UserGetService userGetService; - private final UserCardinalGetService userCardinalGetService; - private final CardinalGetService cardinalGetService; - - private final FileSaveService fileSaveService; - private final FileGetService fileGetService; - private final FileDeleteService fileDeleteService; - - private final PostMapper mapper; - private final FileMapper fileMapper; - private final CommentMapper commentMapper; - - @Override - @Transactional - public PostDTO.SaveResponse save(PostDTO.Save request, Long userId) { - User user = userGetService.find(userId); - - if (request.category() == Category.Education - && !user.hasRole(Role.ADMIN)) { - throw new CategoryAccessDeniedException(); - } - - cardinalGetService.findByUserSide(request.cardinalNumber()); - Post post = mapper.fromPostDto(request, user); - Post savedPost = postSaveService.save(post); - - List files = fileMapper.toFileList(request.files(), post); - fileSaveService.save(files); - - return mapper.toSaveResponse(savedPost); - } - - @Override - @Transactional - public PostDTO.SaveResponse saveEducation(PostDTO.SaveEducation request, Long userId) { - User user = userGetService.find(userId); - - Post post = mapper.fromEducationDto(request, user); - Post saverPost = postSaveService.save(post); - - List files = fileMapper.toFileList(request.files(), post); - fileSaveService.save(files); - - return mapper.toSaveResponse(saverPost); - } - - @Override - public PostDTO.Response findPost(Long postId) { - Post post = postFindService.find(postId); - - List response = getFiles(postId).stream() - .map(fileMapper::toFileResponse) - .toList(); - - return mapper.toPostDto(post, response, filterParentComments(post.getComments())); - } - - @Override - public Slice findPosts(int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.findRecentPosts(pageable); - - return posts.map(post->mapper.toAll(post, checkFileExistsByPost(post.id))); - } - - @Override - public Slice findPartPosts(PartPostDTO dto, int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.findByPartAndOptionalFilters(dto.part(), dto.category(), dto.cardinalNumber(), dto.studyName(), dto.week(), pageable); - - return posts.map(post->mapper.toAll(post, checkFileExistsByPost(post.id))); - } - - @Override - public Slice findEducationPosts(Long userId, Part part, Integer cardinalNumber, int pageNumber, int pageSize) { - User user = userGetService.find(userId); - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - - if (user.hasRole(Role.ADMIN)) { - - return postFindService.findByCategory(part, Category.Education, cardinalNumber, pageNumber, pageSize) - .map(post -> mapper.toEducationAll(post, checkFileExistsByPost(post.getId()))); - } - - if (cardinalNumber != null) { - if (userCardinalGetService.notContains(user, cardinalGetService.findByUserSide(cardinalNumber))) { - return new SliceImpl<>(Collections.emptyList(), pageable, false); - } - Slice posts = postFindService.findEducationByCardinal(part, cardinalNumber, pageable); - return posts.map(post -> mapper.toEducationAll(post, checkFileExistsByPost(post.getId()))); - } - - List userCardinals = userCardinalGetService.getCardinalNumbers(user); - if (userCardinals.isEmpty()) { - return new SliceImpl<>(Collections.emptyList(), pageable, false); - } - Slice posts = postFindService.findEducationByCardinals(part, userCardinals, pageable); - - return posts.map(post -> mapper.toEducationAll(post, checkFileExistsByPost(post.getId()))); - } - - @Override - public PostDTO.ResponseStudyNames findStudyNames(Part part) { - List names = postFindService.findByPart(part); - - return mapper.toStudyNames(names); - } - - @Override - public Slice searchPost(String keyword, int pageNumber, int pageSize){ - validatePageNumber(pageNumber); - - keyword = keyword.strip(); // 문자열 앞뒤 공백 제거 - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.search(keyword, pageable); - - if(posts.isEmpty()){ - throw new NoSearchResultException(); - } - - return posts.map(post->mapper.toAll(post, checkFileExistsByPost(post.id))); - } - - @Override - public Slice searchEducation(String keyword, int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - keyword = keyword.strip(); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.searchEducation(keyword, pageable); - - if(posts.isEmpty()){ - throw new NoSearchResultException(); - } - - return posts.map(post->mapper.toEducationAll(post, checkFileExistsByPost(post.id))); - } - - @Override - @Transactional - public PostDTO.SaveResponse update(Long postId, PostDTO.Update dto, Long userId) { - Post post = validateOwner(postId, userId); - - if (dto.files() != null) { - List fileList = getFiles(postId); - fileDeleteService.delete(fileList); - - List files = fileMapper.toFileList(dto.files(), post); - fileSaveService.save(files); - } - - postUpdateService.update(post, dto); - - return mapper.toSaveResponse(post); - } - - @Override - @Transactional - public PostDTO.SaveResponse updateEducation(Long postId, PostDTO.UpdateEducation dto, Long userId) { - Post post = validateOwner(postId, userId); - - if (dto.files() != null) { - List fileList = getFiles(postId); - fileDeleteService.delete(fileList); - - List files = fileMapper.toFileList(dto.files(), post); - fileSaveService.save(files); - } - - postUpdateService.updateEducation(post, dto); - - return mapper.toSaveResponse(post); - } - - @Override - @Transactional - public void delete(Long postId, Long userId) { - validateOwner(postId, userId); - - List fileList = getFiles(postId); - fileDeleteService.delete(fileList); - - postDeleteService.delete(postId); - } - - private List getFiles(Long postId) { - return fileGetService.findAllByPost(postId); - } - - private Post validateOwner(Long postId, Long userId) { - Post post = postFindService.find(postId); - - if (!post.getUser().getId().equals(userId)) { - throw new UserNotMatchException(); - } - return post; - } - - public boolean checkFileExistsByPost(Long postId){ - return !fileGetService.findAllByPost(postId).isEmpty(); - } - - private List filterParentComments(List comments) { - Map> commentMap = comments.stream() - .filter(comment -> comment.getParent() != null) - .collect(Collectors.groupingBy(comment -> comment.getParent().getId())); - - return comments.stream() - .filter(comment -> comment.getParent() == null) // 부모 댓글만 가져오기 - .map(parent -> mapToDtoWithChildren(parent, commentMap)) - .toList(); - } - - private CommentDTO.Response mapToDtoWithChildren(Comment comment, Map> commentMap) { - List children = commentMap.getOrDefault(comment.getId(), Collections.emptyList()) - .stream() - .map(child -> mapToDtoWithChildren(child, commentMap)) - .collect(Collectors.toList()); - - List files = fileGetService.findAllByComment(comment.getId()).stream() - .map(fileMapper::toFileResponse) - .toList(); - - return commentMapper.toCommentDto(comment, children, files); - } - - private void validatePageNumber(int pageNumber){ - if (pageNumber < 0) { - throw new PageNotFoundException(); - } - } - -} diff --git a/src/main/java/com/weeth/domain/board/application/usecase/PostUsecase.java b/src/main/java/com/weeth/domain/board/application/usecase/PostUsecase.java deleted file mode 100644 index ea365a32..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/PostUsecase.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.PartPostDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import org.springframework.data.domain.Slice; - - -public interface PostUsecase { - - PostDTO.SaveResponse save(PostDTO.Save request, Long userId); - - PostDTO.SaveResponse saveEducation(PostDTO.SaveEducation request, Long userId); - - PostDTO.Response findPost(Long postId); - - Slice findPosts(int pageNumber, int pageSize); - - Slice findPartPosts(PartPostDTO dto, int pageNumber, int pageSize); - - Slice findEducationPosts(Long userId, Part part, Integer cardinalNumber, int pageNumber, int pageSize); - - PostDTO.ResponseStudyNames findStudyNames(Part part); - - PostDTO.SaveResponse update(Long postId, PostDTO.Update dto, Long userId) throws UserNotMatchException; - - PostDTO.SaveResponse updateEducation(Long postId, PostDTO.UpdateEducation dto, Long userId) throws UserNotMatchException; - - void delete(Long postId, Long userId) throws UserNotMatchException; - - Slice searchPost(String keyword, int pageNumber, int pageSize); - - Slice searchEducation(String keyword, int pageNumber, int pageSize); -} diff --git a/src/main/java/com/weeth/domain/board/domain/converter/PartListConverter.java b/src/main/java/com/weeth/domain/board/domain/converter/PartListConverter.java deleted file mode 100644 index 58872ffb..00000000 --- a/src/main/java/com/weeth/domain/board/domain/converter/PartListConverter.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.weeth.domain.board.domain.converter; - -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; -import com.weeth.domain.board.domain.entity.enums.Part; - -@Converter -public class PartListConverter implements AttributeConverter, String> { - - private static final String DELIMITER = ","; - - @Override - public String convertToDatabaseColumn(List parts) { - - return parts.stream() - .map(Part::name) - .collect(Collectors.joining(DELIMITER)); - } - - @Override - public List convertToEntityAttribute(String dbData) { - - return Arrays.stream(dbData.split(DELIMITER)) - .map(Part::valueOf) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/Board.java b/src/main/java/com/weeth/domain/board/domain/entity/Board.java deleted file mode 100644 index 181412df..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/Board.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.weeth.domain.board.domain.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.EntityListeners; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.PrePersist; -import java.util.List; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -@Getter -@MappedSuperclass -@EntityListeners(AuditingEntityListener.class) -@SuperBuilder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Slf4j -public class Board extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - public Long id; - - private String title; - - @Column(columnDefinition = "TEXT") - private String content; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - private Integer commentCount; - - @PrePersist - public void prePersist() { - commentCount = 0; - } - - public void decreaseCommentCount() { - if (commentCount > 0) { - commentCount--; - } - } - - public void updateCommentCount(List comments) { - this.commentCount = (int) comments.stream() - .filter(comment -> !comment.getIsDeleted()) - .count(); - } - - public void updateUpperClass(NoticeDTO.Update dto) { - this.title = dto.title(); - this.content = dto.content(); - } - - public void updateUpperClass(PostDTO.Update dto) { - if (dto.title() != null) this.title = dto.title(); - if (dto.content() != null) this.content = dto.content(); - } - - public void updateUpperClass(PostDTO.UpdateEducation dto) { - if (dto.title() != null) this.title = dto.title(); - if (dto.content() != null) this.content = dto.content(); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/Notice.java b/src/main/java/com/weeth/domain/board/domain/entity/Notice.java deleted file mode 100644 index f63fd4ff..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/Notice.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.weeth.domain.board.domain.entity; - -import com.fasterxml.jackson.annotation.JsonManagedReference; -import jakarta.persistence.Entity; -import jakarta.persistence.OneToMany; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.comment.domain.entity.Comment; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -import java.util.List; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Notice extends Board { - - @OneToMany(mappedBy = "notice", orphanRemoval = true) - @JsonManagedReference - private List comments; - - public void updateCommentCount() { - this.updateCommentCount(this.comments); - } - - public void addComment(Comment comment) { - comments.add(comment); - } - - public void update(NoticeDTO.Update dto){ - this.updateUpperClass(dto); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/Post.java b/src/main/java/com/weeth/domain/board/domain/entity/Post.java deleted file mode 100644 index 4bcb9ffc..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/Post.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.weeth.domain.board.domain.entity; - -import com.fasterxml.jackson.annotation.JsonManagedReference; -import jakarta.persistence.Column; -import jakarta.persistence.Convert; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.OneToMany; -import java.util.ArrayList; -import java.util.List; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.converter.PartListConverter; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.comment.domain.entity.Comment; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Post extends Board { - - @Column - private String studyName; - - @Column(nullable = false) - private int cardinalNumber; - - @Column(nullable=false) - private int week; - - @Enumerated(EnumType.STRING) - private Part part; - - @Column(nullable = false, columnDefinition = "varchar(255)") - @Convert(converter = PartListConverter.class) - private List parts = new ArrayList<>(); - - @Enumerated(EnumType.STRING) - private Category category; - - @OneToMany(mappedBy = "post", orphanRemoval = true) - @JsonManagedReference - private List comments; - - public void updateCommentCount() { - this.updateCommentCount(this.comments); - } - - public void addComment(Comment comment) { - comments.add(comment); - } - - public void update(PostDTO.Update dto) { - this.updateUpperClass(dto); - if (dto.studyName() != null) this.studyName = dto.studyName(); - if (dto.week() != null) this.week = dto.week(); - if (dto.part() != null) { - this.part = dto.part(); - this.parts = List.of(dto.part()); - } - if (dto.cardinalNumber() != null) this.cardinalNumber = dto.cardinalNumber(); - } - - public void updateEducation(PostDTO.UpdateEducation dto) { - this.updateUpperClass(dto); - this.part = null; - if (dto.parts() != null) this.parts = dto.parts(); - if (dto.cardinalNumber() != null) this.cardinalNumber = dto.cardinalNumber(); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/enums/Category.java b/src/main/java/com/weeth/domain/board/domain/entity/enums/Category.java deleted file mode 100644 index 64c59a44..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/enums/Category.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.board.domain.entity.enums; - -public enum Category { - StudyLog, - Article, - Education -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/enums/Part.java b/src/main/java/com/weeth/domain/board/domain/entity/enums/Part.java deleted file mode 100644 index 1af83a71..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/enums/Part.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.domain.entity.enums; - -public enum Part { - D, - BE, - FE, - PM, - ALL -} diff --git a/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java b/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java deleted file mode 100644 index 3def6036..00000000 --- a/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.board.domain.repository; - -import com.weeth.domain.board.domain.entity.Notice; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - - -public interface NoticeRepository extends JpaRepository { - - Slice findPageBy(Pageable page); - - @Query(""" - SELECT n FROM Notice n - WHERE (LOWER(n.title) LIKE LOWER(CONCAT('%', :kw, '%')) - OR LOWER(n.content) LIKE LOWER(CONCAT('%', :kw, '%'))) - ORDER BY n.id DESC - """) - Slice search(@Param("kw") String kw, Pageable pageable); -} diff --git a/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java b/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java deleted file mode 100644 index fff5b870..00000000 --- a/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.weeth.domain.board.domain.repository; - -import java.util.Collection; -import java.util.List; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - - -public interface PostRepository extends JpaRepository { - - @Query(""" - SELECT p FROM Post p - WHERE p.category IN ( - com.weeth.domain.board.domain.entity.enums.Category.StudyLog, - com.weeth.domain.board.domain.entity.enums.Category.Article - ) - ORDER BY p.id DESC - """) - Slice findRecentPart(Pageable pageable); - - @Query(""" - SELECT p FROM Post p - WHERE p.category = com.weeth.domain.board.domain.entity.enums.Category.Education - ORDER BY p.id DESC - """) - Slice findRecentEducation(Pageable pageable); - - @Query(""" - SELECT p FROM Post p - WHERE p.category IN ( - com.weeth.domain.board.domain.entity.enums.Category.StudyLog, - com.weeth.domain.board.domain.entity.enums.Category.Article - ) - AND ( - LOWER(p.title) LIKE LOWER(CONCAT('%', :kw, '%')) - OR LOWER(p.content) LIKE LOWER(CONCAT('%', :kw, '%')) - ) - ORDER BY p.id DESC - """) - Slice searchPart(@Param("kw") String kw, Pageable pageable); - - @Query(""" - SELECT p FROM Post p - WHERE p.category = com.weeth.domain.board.domain.entity.enums.Category.Education - AND ( - LOWER(p.title) LIKE LOWER(CONCAT('%', :kw, '%')) - OR LOWER(p.content) LIKE LOWER(CONCAT('%', :kw, '%')) - ) - ORDER BY p.id DESC - """) - Slice searchEducation(@Param("kw") String kw, Pageable pageable); - - @Query(""" - SELECT DISTINCT p.studyName - FROM Post p - WHERE (:part = com.weeth.domain.board.domain.entity.enums.Part.ALL OR p.part = :part) - AND p.studyName IS NOT NULL - ORDER BY p.studyName ASC - """) - List findDistinctStudyNamesByPart(@Param("part") Part part); - - @Query(""" - SELECT p - FROM Post p - WHERE (p.part = :part OR p.part = com.weeth.domain.board.domain.entity.enums.Part.ALL OR :part = com.weeth.domain.board.domain.entity.enums.Part.ALL - ) - AND (:category IS NULL OR p.category = :category) - AND (:cardinal IS NULL OR p.cardinalNumber = :cardinal) - AND (:studyName IS NULL OR p.studyName = :studyName) - AND (:week IS NULL OR p.week = :week) - ORDER BY p.id DESC - """) - Slice findByPartAndOptionalFilters(@Param("part") Part part, @Param("category") Category category, @Param("cardinal") Integer cardinal, @Param("studyName") String studyName, @Param("week") Integer week, Pageable pageable); - - @Query(""" - SELECT p - FROM Post p - WHERE p.category = :category - AND (:cardinal IS NULL OR p.cardinalNumber = :cardinal) - AND ( - :partName = 'ALL' - OR FUNCTION('FIND_IN_SET', :partName, p.parts) > 0 - OR FUNCTION('FIND_IN_SET', 'ALL', p.parts) > 0 - ) - ORDER BY p.id DESC - """) - Slice findByCategoryAndOptionalCardinalWithPart(@Param("partName") String partName, @Param("category") Category category, @Param("cardinal") Integer cardinal, Pageable pageable); - - @Query(""" - SELECT p - FROM Post p - WHERE p.category = :category - AND p.cardinalNumber = :cardinal - AND ( - :partName = 'ALL' - OR FUNCTION('FIND_IN_SET', :partName, p.parts) > 0 - OR FUNCTION('FIND_IN_SET', 'ALL', p.parts) > 0 - ) - ORDER BY p.id DESC - """) - Slice findByCategoryAndCardinalNumberWithPart(@Param("partName") String partName, @Param("category") Category category, @Param("cardinal") Integer cardinal, Pageable pageable); - - @Query(""" - SELECT p - FROM Post p - WHERE p.category = :category - AND p.cardinalNumber IN :cardinals - AND ( - :partName = 'ALL' - OR FUNCTION('FIND_IN_SET', :partName, p.parts) > 0 - OR FUNCTION('FIND_IN_SET', 'ALL', p.parts) > 0 - ) - ORDER BY p.id DESC - """) - Slice findByCategoryAndCardinalInWithPart(@Param("partName") String partName, @Param("category") Category category, @Param("cardinals") Collection cardinals, Pageable pageable); -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeDeleteService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeDeleteService.java deleted file mode 100644 index af8f72ec..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeDeleteService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.board.domain.repository.NoticeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NoticeDeleteService { - - private final NoticeRepository noticeRepository; - - @Transactional - public void delete(Long noticeId) { - noticeRepository.deleteById(noticeId); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeFindService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeFindService.java deleted file mode 100644 index 0bf77b58..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeFindService.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import java.util.List; -import com.weeth.domain.board.application.exception.NoticeNotFoundException; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.repository.NoticeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NoticeFindService { - - private final NoticeRepository noticeRepository; - - public Notice find(Long noticeId) { - return noticeRepository.findById(noticeId) - .orElseThrow(NoticeNotFoundException::new); - } - - public List find() { - return noticeRepository.findAll(); - } - - - public Slice findRecentNotices(Pageable pageable) { - return noticeRepository.findPageBy(pageable); - } - - public Slice search(String keyword, Pageable pageable) { - if(keyword == null || keyword.isEmpty()){ - return findRecentNotices(pageable); - } - return noticeRepository.search(keyword.strip(), pageable); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeSaveService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeSaveService.java deleted file mode 100644 index a0730dc1..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeSaveService.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.repository.NoticeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NoticeSaveService { - - private final NoticeRepository noticeRepository; - - public Notice save(Notice notice){ - return noticeRepository.save(notice); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeUpdateService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeUpdateService.java deleted file mode 100644 index 0d28974e..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeUpdateService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.domain.entity.Notice; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class NoticeUpdateService { - - public void update(Notice notice, NoticeDTO.Update dto){ - notice.update(dto); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostDeleteService.java b/src/main/java/com/weeth/domain/board/domain/service/PostDeleteService.java deleted file mode 100644 index 25266a05..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostDeleteService.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.board.domain.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostDeleteService { - - private final PostRepository postRepository; - - public void delete(Long postId) { - postRepository.deleteById(postId); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostFindService.java b/src/main/java/com/weeth/domain/board/domain/service/PostFindService.java deleted file mode 100644 index f813c135..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostFindService.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import com.weeth.domain.board.application.exception.PostNotFoundException; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.board.domain.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.SliceImpl; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostFindService { - - private final PostRepository postRepository; - - public Post find(Long postId){ - return postRepository.findById(postId) - .orElseThrow(PostNotFoundException::new); - } - - public List find(){ - return postRepository.findAll(); - } - - public List findByPart(Part part) { - return postRepository.findDistinctStudyNamesByPart(part); - } - - public Slice findRecentPosts(Pageable pageable) { - return postRepository.findRecentPart(pageable); - } - - public Slice findRecentEducationPosts(Pageable pageable) { - return postRepository.findRecentEducation(pageable); - } - - public Slice search(String keyword, Pageable pageable) { - if(keyword == null || keyword.isEmpty()){ - return findRecentPosts(pageable); - } - return postRepository.searchPart(keyword.strip(), pageable); - } - - public Slice searchEducation(String keyword, Pageable pageable) { - if(keyword == null || keyword.isEmpty()){ - return findRecentEducationPosts(pageable); - } - return postRepository.searchEducation(keyword.strip(), pageable); - } - - public Slice findByPartAndOptionalFilters(Part part, Category category, Integer cardinalNumber, String studyName, Integer week, Pageable pageable) { - - return postRepository.findByPartAndOptionalFilters( - part, category, cardinalNumber, studyName, week, pageable - ); - } - - public Slice findEducationByCardinals(Part part, Collection cardinals, Pageable pageable) { - if (cardinals == null || cardinals.isEmpty()) { - return new SliceImpl<>(Collections.emptyList(), pageable, false); - } - String partName = (part != null ? part.name() : Part.ALL.name()); - - return postRepository.findByCategoryAndCardinalInWithPart(partName, Category.Education, cardinals, pageable); - } - - public Slice findEducationByCardinal(Part part, int cardinalNumber, Pageable pageable) { - String partName = (part != null ? part.name() : Part.ALL.name()); - - return postRepository.findByCategoryAndCardinalNumberWithPart(partName, Category.Education, cardinalNumber, pageable); - } - - public Slice findByCategory(Part part, Category category, Integer cardinal, int pageNumber, int pageSize) { - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - String partName = (part != null ? part.name() : Part.ALL.name()); - - return postRepository.findByCategoryAndOptionalCardinalWithPart(partName, category, cardinal, pageable); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostSaveService.java b/src/main/java/com/weeth/domain/board/domain/service/PostSaveService.java deleted file mode 100644 index c1abc88e..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostSaveService { - - private final PostRepository postRepository; - - public Post save(Post post) { - return postRepository.save(post); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostUpdateService.java b/src/main/java/com/weeth/domain/board/domain/service/PostUpdateService.java deleted file mode 100644 index e5c95e93..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostUpdateService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.entity.Post; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostUpdateService { - - public void update(Post post, PostDTO.Update dto){ - post.update(dto); - } - - public void updateEducation(Post post, PostDTO.UpdateEducation dto){ - post.updateEducation(dto); - } -} diff --git a/src/main/java/com/weeth/domain/board/presentation/BoardResponseCode.java b/src/main/java/com/weeth/domain/board/presentation/BoardResponseCode.java deleted file mode 100644 index 2ac54cad..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/BoardResponseCode.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.weeth.domain.board.presentation; -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum BoardResponseCode implements ResponseCodeInterface { - //NoticeAdminController 관련 - NOTICE_CREATED_SUCCESS(1300, HttpStatus.OK, "공지사항이 성공적으로 생성되었습니다."), - NOTICE_UPDATED_SUCCESS(1301, HttpStatus.OK, "공지사항이 성공적으로 수정되었습니다."), - NOTICE_DELETED_SUCCESS(1302, HttpStatus.OK, "공지사항이 성공적으로 삭제되었습니다."), - //NoticeController 관련 - NOTICE_FIND_ALL_SUCCESS(1303, HttpStatus.OK, "공지사항 목록이 성공적으로 조회되었습니다."), - NOTICE_FIND_BY_ID_SUCCESS(1304, HttpStatus.OK, "공지사항이 성공적으로 조회되었습니다."), - NOTICE_SEARCH_SUCCESS(1305, HttpStatus.OK, "공지사항 검색 결과가 성공적으로 조회되었습니다."), - //PostController 관련 - POST_CREATED_SUCCESS(1306, HttpStatus.OK, "게시글이 성공적으로 생성되었습니다."), - POST_UPDATED_SUCCESS(1307, HttpStatus.OK, "파트 게시글이 성공적으로 수정되었습니다."), - POST_DELETED_SUCCESS(1308, HttpStatus.OK, "게시글이 성공적으로 삭제되었습니다."), - POST_FIND_ALL_SUCCESS(1309, HttpStatus.OK, "게시글 목록이 성공적으로 조회되었습니다."), - POST_PART_FIND_ALL_SUCCESS(1310, HttpStatus.OK, "파트별 게시글 목록이 성공적으로 조회되었습니다."), - POST_EDU_FIND_SUCCESS(1311, HttpStatus.OK, "교육 게시글 목록이 성공적으로 조회되었습니다."), - POST_FIND_BY_ID_SUCCESS(1312, HttpStatus.OK, "파트 게시글이 성공적으로 조회되었습니다."), - POST_SEARCH_SUCCESS(1313, HttpStatus.OK, "파트 게시글 검색 결과가 성공적으로 조회되었습니다."), - EDUCATION_SEARCH_SUCCESS(1314, HttpStatus.OK, "교육 자료 검색 결과가 성공적으로 조회되었습니다."), - POST_STUDY_NAMES_FIND_SUCCESS(1315, HttpStatus.OK, "스터디 이름 목록이 성공적으로 조회되었습니다."), - - EDUCATION_UPDATED_SUCCESS(1316, HttpStatus.OK, "교육자료가 성공적으로 수정되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - BoardResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/java/com/weeth/domain/board/presentation/EducationAdminController.java b/src/main/java/com/weeth/domain/board/presentation/EducationAdminController.java deleted file mode 100644 index 4af3cd1f..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/EducationAdminController.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.weeth.domain.board.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.PostErrorCode; -import com.weeth.domain.board.application.usecase.PostUsecase; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.EDUCATION_UPDATED_SUCCESS; -import static com.weeth.domain.board.presentation.BoardResponseCode.POST_CREATED_SUCCESS; - -@Tag(name = "EDUCATION ADMIN", description = "[ADMIN] 공지사항 교육자료 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/educations") -@ApiErrorCodeExample({BoardErrorCode.class, PostErrorCode.class}) -public class EducationAdminController { - private final PostUsecase postUsecase; - - @PostMapping("/education") - @Operation(summary = "교육자료 생성") - public CommonResponse saveEducation(@RequestBody @Valid PostDTO.SaveEducation dto, @Parameter(hidden = true) @CurrentUser Long userId) { - PostDTO.SaveResponse response = postUsecase.saveEducation(dto, userId); - - return CommonResponse.success(POST_CREATED_SUCCESS, response); - } - - @PatchMapping(value = "/{boardId}") - @Operation(summary="교육자료 게시글 수정") - public CommonResponse update(@PathVariable Long boardId, - @RequestBody @Valid PostDTO.UpdateEducation dto, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - PostDTO.SaveResponse response = postUsecase.updateEducation(boardId, dto, userId); - - return CommonResponse.success(EDUCATION_UPDATED_SUCCESS, response); - } -} diff --git a/src/main/java/com/weeth/domain/board/presentation/NoticeAdminController.java b/src/main/java/com/weeth/domain/board/presentation/NoticeAdminController.java deleted file mode 100644 index 94b61a01..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/NoticeAdminController.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.weeth.domain.board.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.NoticeErrorCode; -import com.weeth.domain.board.application.usecase.NoticeUsecase; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.*; - -@Tag(name = "NOTICE ADMIN", description = "[ADMIN] 공지사항 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/notices") -@ApiErrorCodeExample({BoardErrorCode.class, NoticeErrorCode.class}) -public class NoticeAdminController { - - private final NoticeUsecase noticeUsecase; - - @PostMapping - @Operation(summary="공지사항 생성") - public CommonResponse save(@RequestBody @Valid NoticeDTO.Save dto, - @Parameter(hidden = true) @CurrentUser Long userId) { - NoticeDTO.SaveResponse response = noticeUsecase.save(dto, userId); - - return CommonResponse.success(NOTICE_CREATED_SUCCESS, response); - } - - @PatchMapping(value = "/{noticeId}") - @Operation(summary="특정 공지사항 수정") - public CommonResponse update(@PathVariable Long noticeId, - @RequestBody @Valid NoticeDTO.Update dto, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - NoticeDTO.SaveResponse response = noticeUsecase.update(noticeId, dto, userId); - - return CommonResponse.success(NOTICE_UPDATED_SUCCESS, response); - } - - @DeleteMapping("/{noticeId}") - @Operation(summary="특정 공지사항 삭제") - public CommonResponse delete(@PathVariable Long noticeId, @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - noticeUsecase.delete(noticeId, userId); - return CommonResponse.success(NOTICE_DELETED_SUCCESS); - } - -} diff --git a/src/main/java/com/weeth/domain/board/presentation/NoticeController.java b/src/main/java/com/weeth/domain/board/presentation/NoticeController.java deleted file mode 100644 index 5ddeb287..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/NoticeController.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.weeth.domain.board.presentation; - - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.NoticeErrorCode; -import com.weeth.domain.board.application.usecase.NoticeUsecase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Slice; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.*; - - -@Tag(name = "NOTICE", description = "공지사항 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/notices") -@ApiErrorCodeExample({BoardErrorCode.class, NoticeErrorCode.class}) -public class NoticeController { - - private final NoticeUsecase noticeUsecase; - - @GetMapping - @Operation(summary="공지사항 목록 조회 [무한스크롤]") - public CommonResponse> findNotices(@RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(NOTICE_FIND_ALL_SUCCESS, noticeUsecase.findNotices(pageNumber, pageSize)); - } - - @GetMapping("/{noticeId}") - @Operation(summary="특정 공지사항 조회") - public CommonResponse findNoticeById(@PathVariable Long noticeId) { - return CommonResponse.success(NOTICE_FIND_BY_ID_SUCCESS, noticeUsecase.findNotice(noticeId)); - } - - @GetMapping("/search") - @Operation(summary="공지사항 검색 [무한스크롤]") - public CommonResponse> findNotice(@RequestParam String keyword, @RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(NOTICE_SEARCH_SUCCESS, noticeUsecase.searchNotice(keyword, pageNumber, pageSize)); - } -} diff --git a/src/main/java/com/weeth/domain/board/presentation/PostController.java b/src/main/java/com/weeth/domain/board/presentation/PostController.java deleted file mode 100644 index 633ce6a2..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/PostController.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.weeth.domain.board.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.board.application.dto.PartPostDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.PostErrorCode; -import com.weeth.domain.board.application.usecase.PostUsecase; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Slice; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.*; - -@Tag(name = "BOARD", description = "게시판 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/board") -@ApiErrorCodeExample({BoardErrorCode.class, PostErrorCode.class}) -public class PostController { - - private final PostUsecase postUsecase; - - @PostMapping - @Operation(summary="파트 게시글 생성 (스터디 로그, 아티클)") - public CommonResponse save(@RequestBody @Valid PostDTO.Save dto, @Parameter(hidden = true) @CurrentUser Long userId) { - PostDTO.SaveResponse response = postUsecase.save(dto, userId); - - return CommonResponse.success(POST_CREATED_SUCCESS, response); - } - - @GetMapping - @Operation(summary="게시글 목록 조회 [무한스크롤]") - public CommonResponse> findPosts(@RequestParam("pageNumber") int pageNumber, - @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(POST_FIND_ALL_SUCCESS, postUsecase.findPosts(pageNumber, pageSize)); - } - - @GetMapping("/part") - @Operation(summary="파트별 스터디 게시글 목록 조회 [무한스크롤]") - public CommonResponse> findPartPosts(@ModelAttribute @Valid PartPostDTO dto, @RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize) { - Slice response = postUsecase.findPartPosts(dto, pageNumber, pageSize); - - return CommonResponse.success(POST_PART_FIND_ALL_SUCCESS, response); - } - - @GetMapping("/education") - @Operation(summary="교육자료 조회 [무한스크롤]") - public CommonResponse> findEducationMaterials(@RequestParam Part part, @RequestParam(required = false) Integer cardinalNumber, @RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize, @Parameter(hidden = true) @CurrentUser Long userId) { - - return CommonResponse.success(POST_EDU_FIND_SUCCESS, postUsecase.findEducationPosts(userId, part, cardinalNumber, pageNumber, pageSize)); - } - - @GetMapping("/{boardId}") - @Operation(summary="특정 게시글 조회") - public CommonResponse findPost(@PathVariable Long boardId) { - return CommonResponse.success(POST_FIND_BY_ID_SUCCESS, postUsecase.findPost(boardId)); - } - - @GetMapping("/part/studies") - @Operation(summary="파트별 스터디 이름 목록 조회") - public CommonResponse findStudyNames(@RequestParam Part part) { - - return CommonResponse.success(BoardResponseCode.POST_STUDY_NAMES_FIND_SUCCESS, postUsecase.findStudyNames(part)); - } - - @GetMapping("/search/part") - @Operation(summary="파트 게시글 검색 [무한스크롤]") - public CommonResponse> findPost(@RequestParam String keyword, @RequestParam("pageNumber") int pageNumber, - @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(POST_SEARCH_SUCCESS, postUsecase.searchPost(keyword, pageNumber, pageSize)); - } - - @GetMapping("/search/education") - @Operation(summary="교육자료 검색 [무한스크롤]") - public CommonResponse> findEducation(@RequestParam String keyword, @RequestParam("pageNumber") int pageNumber, - @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(EDUCATION_SEARCH_SUCCESS, postUsecase.searchEducation(keyword, pageNumber, pageSize)); - } - - @PatchMapping(value = "/{boardId}/part") - @Operation(summary="파트 게시글 수정") - public CommonResponse update(@PathVariable Long boardId, - @RequestBody @Valid PostDTO.Update dto, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - PostDTO.SaveResponse response = postUsecase.update(boardId, dto, userId); - - return CommonResponse.success(POST_UPDATED_SUCCESS, response); - } - - @DeleteMapping("/{boardId}") - @Operation(summary="특정 게시글 삭제") - public CommonResponse delete(@PathVariable Long boardId, @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - postUsecase.delete(boardId, userId); - return CommonResponse.success(POST_DELETED_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/comment/application/dto/CommentDTO.java b/src/main/java/com/weeth/domain/comment/application/dto/CommentDTO.java deleted file mode 100644 index 94938d6c..00000000 --- a/src/main/java/com/weeth/domain/comment/application/dto/CommentDTO.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.weeth.domain.comment.application.dto; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public class CommentDTO { - - @Builder - public record Save( - Long parentCommentId, - @NotBlank @Size(max=300, message = "댓글은 최대 300자까지 가능합니다.") String content, - @Valid List<@NotNull FileSaveRequest> files - ){} - - @Builder - public record Update( - @NotBlank @Size(max=300, message = "댓글은 최대 300자까지 가능합니다.") String content, - @Valid List<@NotNull FileSaveRequest> files - ){} - - @Builder - public record Response( - Long id, - String name, - Position position, - Role role, - String content, - LocalDateTime time, //modifiedAt - List fileUrls, - List children - ){} - -} diff --git a/src/main/java/com/weeth/domain/comment/application/exception/CommentErrorCode.java b/src/main/java/com/weeth/domain/comment/application/exception/CommentErrorCode.java deleted file mode 100644 index 4fdf58b0..00000000 --- a/src/main/java/com/weeth/domain/comment/application/exception/CommentErrorCode.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.comment.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum CommentErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 댓글 ID에 해당하는 댓글이 존재하지 않을 때 발생합니다.") - COMMENT_NOT_FOUND(2400, HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/comment/application/exception/CommentNotFoundException.java b/src/main/java/com/weeth/domain/comment/application/exception/CommentNotFoundException.java deleted file mode 100644 index c99942a3..00000000 --- a/src/main/java/com/weeth/domain/comment/application/exception/CommentNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.comment.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class CommentNotFoundException extends BaseException { - public CommentNotFoundException() { - super(CommentErrorCode.COMMENT_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/comment/application/mapper/CommentMapper.java b/src/main/java/com/weeth/domain/comment/application/mapper/CommentMapper.java deleted file mode 100644 index 757fd587..00000000 --- a/src/main/java/com/weeth/domain/comment/application/mapper/CommentMapper.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.weeth.domain.comment.application.mapper; - -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface CommentMapper { - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "createdAt", ignore = true), - @Mapping(target = "modifiedAt", ignore = true), - @Mapping(target = "isDeleted", ignore = true), - @Mapping(target = "notice", ignore = true), - @Mapping(target = "user", source = "user"), - @Mapping(target = "parent", source = "parent"), - @Mapping(target = "content", source = "dto.content"), - @Mapping(target = "post", source = "post") - }) - Comment fromCommentDto(CommentDTO.Save dto, Post post, User user, Comment parent); - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "createdAt", ignore = true), - @Mapping(target = "modifiedAt", ignore = true), - @Mapping(target = "isDeleted", ignore = true), - @Mapping(target = "post", ignore = true), - @Mapping(target = "user", source = "user"), - @Mapping(target = "parent", source = "parent"), - @Mapping(target = "content", source = "dto.content"), - @Mapping(target = "notice", source = "notice") - }) - Comment fromCommentDto(CommentDTO.Save dto, Notice notice, User user, Comment parent); - - - @Mapping(target = "name", source = "comment.user.name") - @Mapping(target = "position", source = "comment.user.position") - @Mapping(target = "role", source = "comment.user.role") - @Mapping(target = "time", source = "comment.modifiedAt") - @Mapping(target = "children", source = "children") - CommentDTO.Response toCommentDto(Comment comment, List children, List fileUrls); -} diff --git a/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecase.java b/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecase.java deleted file mode 100644 index 4a142a59..00000000 --- a/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecase.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.domain.comment.application.usecase; - -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.user.application.exception.UserNotMatchException; - -public interface NoticeCommentUsecase { - - void saveNoticeComment(CommentDTO.Save dto, Long noticeId, Long userId); - - void updateNoticeComment(CommentDTO.Update dto, Long noticeId, Long commentId, Long userId) throws UserNotMatchException; - - void deleteNoticeComment(Long commentId, Long userId) throws UserNotMatchException; -} diff --git a/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImpl.java b/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImpl.java deleted file mode 100644 index e0137b98..00000000 --- a/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImpl.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.weeth.domain.comment.application.usecase; - -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.service.NoticeFindService; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.exception.CommentNotFoundException; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.comment.domain.service.CommentDeleteService; -import com.weeth.domain.comment.domain.service.CommentFindService; -import com.weeth.domain.comment.domain.service.CommentSaveService; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.service.FileDeleteService; -import com.weeth.domain.file.domain.service.FileGetService; -import com.weeth.domain.file.domain.service.FileSaveService; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class NoticeCommentUsecaseImpl implements NoticeCommentUsecase { - - private final CommentSaveService commentSaveService; - private final CommentFindService commentFindService; - private final CommentDeleteService commentDeleteService; - - private final FileSaveService fileSaveService; - private final FileGetService fileGetService; - private final FileDeleteService fileDeleteService; - private final FileMapper fileMapper; - - private final NoticeFindService noticeFindService; - - private final UserGetService userGetService; - private final CommentMapper commentMapper; - - @Override - @Transactional - public void saveNoticeComment(CommentDTO.Save dto, Long noticeId, Long userId) { - User user = userGetService.find(userId); - Notice notice = noticeFindService.find(noticeId); - Comment parentComment = null; - - if(!(dto.parentCommentId() == null)) { - parentComment = commentFindService.find(dto.parentCommentId()); - } - Comment comment = commentMapper.fromCommentDto(dto, notice, user, parentComment); - commentSaveService.save(comment); - - List files = fileMapper.toFileList(dto.files(), comment); - fileSaveService.save(files); - - // 부모 댓글이 없다면 새 댓글로 추가 - if(parentComment == null) { - notice.addComment(comment); - } else { - // 부모 댓글이 있다면 자녀 댓글로 추가 - parentComment.addChild(comment); - } - notice.updateCommentCount(); - } - - @Override - @Transactional - public void updateNoticeComment(CommentDTO.Update dto, Long noticeId, Long commentId, Long userId) throws UserNotMatchException { - User user = userGetService.find(userId); - Notice notice = noticeFindService.find(noticeId); - Comment comment = validateOwner(commentId, userId); - - List fileList = getFiles(commentId); - fileDeleteService.delete(fileList); - - List files = fileMapper.toFileList(dto.files(), comment); - fileSaveService.save(files); - - comment.update(dto); - } - - @Override - @Transactional - public void deleteNoticeComment(Long commentId, Long userId) throws UserNotMatchException { - User user = userGetService.find(userId); - Comment comment = validateOwner(commentId, userId); - Notice notice = comment.getNotice(); - - List fileList = getFiles(commentId); - fileDeleteService.delete(fileList); - - if (comment.getChildren().isEmpty()) { - Comment parentComment = findParentComment(commentId); - commentDeleteService.delete(commentId); - if (parentComment != null) { - parentComment.getChildren().remove(comment); - if (parentComment.getIsDeleted() && parentComment.getChildren().isEmpty()) { - notice.getComments().remove(parentComment); - commentDeleteService.delete(parentComment.getId()); - } - } - } else if (comment.getIsDeleted()) { // 삭제된 대댓글인 경우 예외 - throw new CommentNotFoundException(); - } else { - comment.markAsDeleted(); - commentSaveService.save(comment); - } - notice.decreaseCommentCount(); - } - - private Comment findParentComment(Long commentId) { - List comments = commentFindService.find(); - for (Comment comment : comments) { - if (comment.getChildren().stream().anyMatch(child -> child.getId().equals(commentId))) { - return comment; - } - } - return null; // 부모 댓글을 찾지 못한 경우 - } - - private Comment validateOwner(Long commentId, Long userId) throws UserNotMatchException { - Comment comment = commentFindService.find(commentId); - - if (!comment.getUser().getId().equals(userId)) { - throw new UserNotMatchException(); - } - return comment; - } - - private List getFiles(Long commentId) { - return fileGetService.findAllByComment(commentId); - } -} diff --git a/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecase.java b/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecase.java deleted file mode 100644 index 50e0633e..00000000 --- a/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecase.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.comment.application.usecase; - -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.user.application.exception.UserNotMatchException; - -public interface PostCommentUsecase { - - void savePostComment(CommentDTO.Save dto, Long postId, Long userId); - - void updatePostComment(CommentDTO.Update dto, Long postId, Long commentId, Long userId) throws UserNotMatchException; - - void deletePostComment(Long commentId, Long userId) throws UserNotMatchException; - -} diff --git a/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecaseImpl.java b/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecaseImpl.java deleted file mode 100644 index 3a26ba64..00000000 --- a/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecaseImpl.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.weeth.domain.comment.application.usecase; - -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.service.PostFindService; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.exception.CommentNotFoundException; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.comment.domain.service.CommentDeleteService; -import com.weeth.domain.comment.domain.service.CommentFindService; -import com.weeth.domain.comment.domain.service.CommentSaveService; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.service.FileDeleteService; -import com.weeth.domain.file.domain.service.FileGetService; -import com.weeth.domain.file.domain.service.FileSaveService; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class PostCommentUsecaseImpl implements PostCommentUsecase { - - private final CommentSaveService commentSaveService; - private final CommentFindService commentFindService; - private final CommentDeleteService commentDeleteService; - - private final FileSaveService fileSaveService; - private final FileGetService fileGetService; - private final FileDeleteService fileDeleteService; - private final FileMapper fileMapper; - - private final UserGetService userGetService; - - private final PostFindService postFindService; - - private final CommentMapper commentMapper; - - @Override - @Transactional - public void savePostComment(CommentDTO.Save dto, Long postId, Long userId) { - User user = userGetService.find(userId); - Post post = postFindService.find(postId); - Comment parentComment = null; - - if (!(dto.parentCommentId() == null)) { - parentComment = commentFindService.find(dto.parentCommentId()); - } - Comment comment = commentMapper.fromCommentDto(dto, post, user, parentComment); - commentSaveService.save(comment); - - List files = fileMapper.toFileList(dto.files(), comment); - fileSaveService.save(files); - - // 부모 댓글이 없다면 새 댓글로 추가 - if (parentComment == null) { - post.addComment(comment); - } else { - // 부모 댓글이 있다면 자녀 댓글로 추가 - parentComment.addChild(comment); - } - post.updateCommentCount(); - } - - @Override - @Transactional - public void updatePostComment(CommentDTO.Update dto, Long postId, Long commentId, Long userId) throws UserNotMatchException { - User user = userGetService.find(userId); - Post post = postFindService.find(postId); - Comment comment = validateOwner(commentId, userId); - - List fileList = getFiles(commentId); - fileDeleteService.delete(fileList); - - List files = fileMapper.toFileList(dto.files(), comment); - fileSaveService.save(files); - - comment.update(dto); - } - - - @Override - @Transactional - public void deletePostComment(Long commentId, Long userId) throws UserNotMatchException { - User user = userGetService.find(userId); - Comment comment = validateOwner(commentId, userId); - Post post = comment.getPost(); - - List fileList = getFiles(commentId); - fileDeleteService.delete(fileList); - - /* - 1. 지우고자 하는 댓글이 맨 아래층인 경우(child, child가 없는 댓글 - - 현재 댓글.getChildren이 NULL 이면 해당 - - 내가 child인지 child가 없는 댓글인지 구분해야함 - - child인 경우 -> 부모가 있음. 하지만 부모를 삭제하는게 아니라 나만 삭제함, 부모의 childern에서 나를 제거해야함 - - child가 없는 댓글인 경우 -> 자식이 없기 떄문에 나만 삭제함 - */ - // 현재 삭제하고자 하는 댓글이 자식이 없는 경우 - if (comment.getChildren().isEmpty()) { - Comment parentComment = findParentComment(commentId); - commentDeleteService.delete(commentId); - if (parentComment != null) { - parentComment.getChildren().remove(comment); - if (parentComment.getIsDeleted() && parentComment.getChildren().isEmpty()) { - post.getComments().remove(parentComment); - commentDeleteService.delete(parentComment.getId()); - } - } - } else if (comment.getIsDeleted()) { // 삭제된 대댓글인 경우 예외 - throw new CommentNotFoundException(); - } else { - comment.markAsDeleted(); - commentSaveService.save(comment); - } - post.decreaseCommentCount(); - } - - private Comment findParentComment(Long commentId) { - List comments = commentFindService.find(); - for (Comment comment : comments) { - if (comment.getChildren().stream().anyMatch(child -> child.getId().equals(commentId))) { - return comment; - } - } - return null; // 부모 댓글을 찾지 못한 경우 - } - - // 업데이트 메소드를 엔티티 안에서 변경감지로 사용하기로 했기 때문에, 반환 값이 필요 없짐 -> 나머지도 다 수정 - private Comment validateOwner(Long commentId, Long userId) throws UserNotMatchException { - Comment comment = commentFindService.find(commentId); - - if (!comment.getUser().getId().equals(userId)) { - throw new UserNotMatchException(); - } - return comment; - } - - private List getFiles(Long commentId) { - return fileGetService.findAllByComment(commentId); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/domain/entity/Comment.java b/src/main/java/com/weeth/domain/comment/domain/entity/Comment.java deleted file mode 100644 index a3beeb59..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/entity/Comment.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.weeth.domain.comment.domain.entity; - - -import com.fasterxml.jackson.annotation.JsonBackReference; -import jakarta.persistence.*; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.global.common.entity.BaseEntity; -import lombok.*; -import lombok.experimental.SuperBuilder; -import org.hibernate.annotations.ColumnDefault; - -import java.util.ArrayList; -import java.util.List; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@SuperBuilder -public class Comment extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "comment_id") - private Long id; - - @Column(length = 300) - private String content; - - @Column(nullable = false) - private Boolean isDeleted; - - @ManyToOne - @JoinColumn(name="post_id") - @JsonBackReference - private Post post; - - @ManyToOne - @JoinColumn(name="notice_id") - @JsonBackReference - private Notice notice; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "parent_id") - private Comment parent; - - @OneToMany(mappedBy = "parent", cascade = CascadeType.REMOVE, fetch = FetchType.LAZY) - private List children = new ArrayList<>(); - - public void addChild(Comment child) { - this.children.add(child); - } - - @PrePersist - public void prePersist() { - if (isDeleted == null) { - isDeleted = false; - } - } - - //TODO 문자열 상수처리 - public void markAsDeleted() { - this.isDeleted = true; - this.content = "삭제된 댓글입니다."; - } - - public void update(CommentDTO.Update dto) { - this.content = dto.content(); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/domain/repository/CommentRepository.java b/src/main/java/com/weeth/domain/comment/domain/repository/CommentRepository.java deleted file mode 100644 index d37043be..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/repository/CommentRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.comment.domain.repository; - -import com.weeth.domain.comment.domain.entity.Comment; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CommentRepository extends JpaRepository { -} diff --git a/src/main/java/com/weeth/domain/comment/domain/service/CommentDeleteService.java b/src/main/java/com/weeth/domain/comment/domain/service/CommentDeleteService.java deleted file mode 100644 index 4f02b73e..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/service/CommentDeleteService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.comment.domain.service; - -import com.weeth.domain.comment.domain.repository.CommentRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class CommentDeleteService { - - private final CommentRepository commentRepository; - - @Transactional - public void delete(Long commentId) { - commentRepository.deleteById(commentId); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/domain/service/CommentFindService.java b/src/main/java/com/weeth/domain/comment/domain/service/CommentFindService.java deleted file mode 100644 index 1d455677..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/service/CommentFindService.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.weeth.domain.comment.domain.service; - -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.comment.domain.repository.CommentRepository; -import com.weeth.domain.comment.application.exception.CommentNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class CommentFindService { - - private final CommentRepository commentRepository; - - public Comment find(Long commentId) { - return commentRepository.findById(commentId) - .orElseThrow(CommentNotFoundException::new); - } - - public List find() { - return commentRepository.findAll(); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/domain/service/CommentSaveService.java b/src/main/java/com/weeth/domain/comment/domain/service/CommentSaveService.java deleted file mode 100644 index 7712d1b0..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/service/CommentSaveService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.weeth.domain.comment.domain.service; - -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.comment.domain.repository.CommentRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class CommentSaveService { - - private final CommentRepository commentRepository; - - @Transactional - public void save(Comment comment){ - commentRepository.save(comment); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/domain/service/CommentUpdateService.java b/src/main/java/com/weeth/domain/comment/domain/service/CommentUpdateService.java deleted file mode 100644 index 3580ff76..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/service/CommentUpdateService.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.comment.domain.service; - -import com.weeth.domain.comment.domain.repository.CommentRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class CommentUpdateService { - - private final CommentRepository commentRepository; - -} diff --git a/src/main/java/com/weeth/domain/comment/presentation/CommentResponseCode.java b/src/main/java/com/weeth/domain/comment/presentation/CommentResponseCode.java deleted file mode 100644 index 75009a3b..00000000 --- a/src/main/java/com/weeth/domain/comment/presentation/CommentResponseCode.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.weeth.domain.comment.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum CommentResponseCode implements ResponseCodeInterface { - // NoticeCommentController 관련 - COMMENT_CREATED_SUCCESS(1400, HttpStatus.OK, "공지사항 댓글이 성공적으로 생성되었습니다."), - COMMENT_UPDATED_SUCCESS(1401, HttpStatus.OK, "공지사항 댓글이 성공적으로 수정되었습니다."), - COMMENT_DELETED_SUCCESS(1402, HttpStatus.OK, "공지사항 댓글이 성공적으로 삭제되었습니다."), - // PostCommentController 관련 - POST_COMMENT_CREATED_SUCCESS(1403, HttpStatus.OK, "게시글 댓글이 성공적으로 생성되었습니다."), - POST_COMMENT_UPDATED_SUCCESS(1404, HttpStatus.OK, "게시글 댓글이 성공적으로 수정되었습니다."), - POST_COMMENT_DELETED_SUCCESS(1405, HttpStatus.OK, "게시글 댓글이 성공적으로 삭제되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - CommentResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/java/com/weeth/domain/comment/presentation/NoticeCommentController.java b/src/main/java/com/weeth/domain/comment/presentation/NoticeCommentController.java deleted file mode 100644 index e69a0033..00000000 --- a/src/main/java/com/weeth/domain/comment/presentation/NoticeCommentController.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.weeth.domain.comment.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.exception.CommentErrorCode; -import com.weeth.domain.comment.application.usecase.NoticeCommentUsecase; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.comment.presentation.CommentResponseCode.*; - -@Tag(name = "COMMENT-NOTICE", description = "공지사항 댓글 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/notices/{noticeId}/comments") -@ApiErrorCodeExample(CommentErrorCode.class) -public class NoticeCommentController { - - private final NoticeCommentUsecase noticeCommentUsecase; - - @PostMapping - @Operation(summary="공지사항 댓글 작성") - public CommonResponse saveNoticeComment(@RequestBody @Valid CommentDTO.Save dto, @PathVariable Long noticeId, - @Parameter(hidden = true) @CurrentUser Long userId) { - noticeCommentUsecase.saveNoticeComment(dto, noticeId, userId); - return CommonResponse.success(COMMENT_CREATED_SUCCESS); - } - - @PatchMapping("{commentId}") - @Operation(summary="공지사항 댓글 수정") - public CommonResponse updateNoticeComment(@RequestBody @Valid CommentDTO.Update dto, @PathVariable Long noticeId, - @PathVariable Long commentId,@Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - noticeCommentUsecase.updateNoticeComment(dto, noticeId, commentId, userId); - return CommonResponse.success(COMMENT_UPDATED_SUCCESS); - } - - @DeleteMapping("{commentId}") - @Operation(summary="공지사항 댓글 삭제") - public CommonResponse deleteNoticeComment(@PathVariable Long commentId, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - noticeCommentUsecase.deleteNoticeComment(commentId, userId); - return CommonResponse.success(COMMENT_DELETED_SUCCESS); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/presentation/PostCommentController.java b/src/main/java/com/weeth/domain/comment/presentation/PostCommentController.java deleted file mode 100644 index e6da122a..00000000 --- a/src/main/java/com/weeth/domain/comment/presentation/PostCommentController.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.weeth.domain.comment.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.exception.CommentErrorCode; -import com.weeth.domain.comment.application.usecase.PostCommentUsecase; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.comment.presentation.CommentResponseCode.*; - -@Tag(name = "COMMENT-BOARD", description = "게시판 댓글 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/board/{boardId}/comments") -@ApiErrorCodeExample(CommentErrorCode.class) -public class PostCommentController { - - private final PostCommentUsecase postCommentUsecase; - - @PostMapping - @Operation(summary="게시글 댓글 작성") - public CommonResponse savePostComment(@RequestBody @Valid CommentDTO.Save dto, @PathVariable Long boardId, - @Parameter(hidden = true) @CurrentUser Long userId) { - postCommentUsecase.savePostComment(dto, boardId, userId); - return CommonResponse.success(POST_COMMENT_CREATED_SUCCESS); - } - - @PatchMapping("/{commentId}") - @Operation(summary="게시글 댓글 수정") - public CommonResponse updatePostComment(@RequestBody @Valid CommentDTO.Update dto, @PathVariable Long boardId, @PathVariable Long commentId, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - postCommentUsecase.updatePostComment(dto, boardId, commentId, userId); - return CommonResponse.success(POST_COMMENT_UPDATED_SUCCESS); - } - - @DeleteMapping("{commentId}") - @Operation(summary="게시글 댓글 삭제") - public CommonResponse deletePostComment(@PathVariable Long commentId, @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - postCommentUsecase.deletePostComment(commentId, userId); - return CommonResponse.success(POST_COMMENT_DELETED_SUCCESS); - } - -} diff --git a/src/main/java/com/weeth/domain/file/application/dto/request/FileSaveRequest.java b/src/main/java/com/weeth/domain/file/application/dto/request/FileSaveRequest.java deleted file mode 100644 index dda1a2ca..00000000 --- a/src/main/java/com/weeth/domain/file/application/dto/request/FileSaveRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.domain.file.application.dto.request; - -import jakarta.validation.constraints.NotBlank; -import org.hibernate.validator.constraints.URL; - -public record FileSaveRequest( - @NotBlank String fileName, - @NotBlank @URL String fileUrl -) { -} diff --git a/src/main/java/com/weeth/domain/file/application/dto/request/FileUpdateRequest.java b/src/main/java/com/weeth/domain/file/application/dto/request/FileUpdateRequest.java deleted file mode 100644 index 2aa000ca..00000000 --- a/src/main/java/com/weeth/domain/file/application/dto/request/FileUpdateRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.domain.file.application.dto.request; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import org.hibernate.validator.constraints.URL; - -public record FileUpdateRequest( - @NotNull Long fileId, - @NotBlank String fileName, - @NotBlank @URL String fileUrl -) { -} diff --git a/src/main/java/com/weeth/domain/file/application/dto/response/FileResponse.java b/src/main/java/com/weeth/domain/file/application/dto/response/FileResponse.java deleted file mode 100644 index 480de7ef..00000000 --- a/src/main/java/com/weeth/domain/file/application/dto/response/FileResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.domain.file.application.dto.response; - -public record FileResponse( - long fileId, - String fileName, - String fileUrl -) { -} diff --git a/src/main/java/com/weeth/domain/file/application/dto/response/UrlResponse.java b/src/main/java/com/weeth/domain/file/application/dto/response/UrlResponse.java deleted file mode 100644 index 0c55cb13..00000000 --- a/src/main/java/com/weeth/domain/file/application/dto/response/UrlResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.file.application.dto.response; - -public record UrlResponse( - String fileName, - String putUrl -) { -} diff --git a/src/main/java/com/weeth/domain/file/application/mapper/FileMapper.java b/src/main/java/com/weeth/domain/file/application/mapper/FileMapper.java deleted file mode 100644 index 80727aed..00000000 --- a/src/main/java/com/weeth/domain/file/application/mapper/FileMapper.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.weeth.domain.file.application.mapper; - -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.file.application.dto.response.UrlResponse; -import com.weeth.domain.file.domain.entity.File; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.MappingConstants; -import org.mapstruct.ReportingPolicy; - -import java.util.Collections; -import java.util.List; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = CommentMapper.class, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface FileMapper { - - @Mapping(target = "id", ignore = true) - @Mapping(target = "post", source = "post") - File toFileWithPost(String fileName, String fileUrl, Post post); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "notice", source = "notice") - File toFileWithNotice(String fileName, String fileUrl, Notice notice); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "receipt", source = "receipt") - File toFileWithReceipt(String fileName, String fileUrl, Receipt receipt); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "comment", source = "comment") - @Mapping(target = "notice", ignore = true) // notice 필드는 매핑하지 않도록 명시 - @Mapping(target = "post", ignore = true) // post 필드는 매핑하지 않도록 명시 - File toFileWithComment(String fileName, String fileUrl, Comment comment); - - @Mapping(target = "fileId", source = "file.id") - FileResponse toFileResponse(File file); - - UrlResponse toUrlResponse(String fileName, String putUrl); - - private List mapRequestsToFiles(List requests, Function mapper) { - if (requests == null || requests.isEmpty()) { - return Collections.emptyList(); - } - return requests.stream() - .map(mapper) - .collect(Collectors.toList()); - } - - default List toFileList(List requests, Post post) { - return mapRequestsToFiles(requests, request -> toFileWithPost(request.fileName(), request.fileUrl(), post)); - } - - default List toFileList(List requests, Notice notice) { - return mapRequestsToFiles(requests, request -> toFileWithNotice(request.fileName(), request.fileUrl(), notice)); - } - - default List toFileList(List requests, Receipt receipt) { - return mapRequestsToFiles(requests, request -> toFileWithReceipt(request.fileName(), request.fileUrl(), receipt)); - } - - default List toFileList(List requests, Comment comment) { - return mapRequestsToFiles(requests, request -> toFileWithComment(request.fileName(), request.fileUrl(), comment)); - } -} diff --git a/src/main/java/com/weeth/domain/file/application/usecase/FileManageUseCase.java b/src/main/java/com/weeth/domain/file/application/usecase/FileManageUseCase.java deleted file mode 100644 index ff94dd8e..00000000 --- a/src/main/java/com/weeth/domain/file/application/usecase/FileManageUseCase.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.file.application.usecase; - -import jakarta.transaction.Transactional; -import com.weeth.domain.file.application.dto.response.UrlResponse; -import com.weeth.domain.file.domain.service.PreSignedService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class FileManageUseCase { - - private final PreSignedService preSignedService; - - public List getUrl(List fileNames) { - return fileNames.stream() - .map(preSignedService::generateUrl) - .toList(); - } -} diff --git a/src/main/java/com/weeth/domain/file/domain/entity/File.java b/src/main/java/com/weeth/domain/file/domain/entity/File.java deleted file mode 100644 index fcce771a..00000000 --- a/src/main/java/com/weeth/domain/file/domain/entity/File.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.weeth.domain.file.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Getter -@SuperBuilder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Entity -public class File extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String fileName; - - private String fileUrl; - - @ManyToOne - @JoinColumn(name = "post_id") - private Post post; - - @ManyToOne - @JoinColumn(name = "notice_id") - private Notice notice; - - @ManyToOne - @JoinColumn(name = "receipt_id") - private Receipt receipt; - - @ManyToOne - @JoinColumn(name = "comment_id") - private Comment comment; - - public void update(String fileName, String fileUrl) { - this.fileName = fileName; - this.fileUrl = fileUrl; - } -} diff --git a/src/main/java/com/weeth/domain/file/domain/repository/FileRepository.java b/src/main/java/com/weeth/domain/file/domain/repository/FileRepository.java deleted file mode 100644 index de0b53ac..00000000 --- a/src/main/java/com/weeth/domain/file/domain/repository/FileRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.file.domain.repository; - -import com.weeth.domain.file.domain.entity.File; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface FileRepository extends JpaRepository { - - List findAllByPostId(Long postId); - - List findAllByNoticeId(Long noticeId); - - List findAllByReceiptId(Long receiptId); - - List findAllByCommentId(Long commentId); -} diff --git a/src/main/java/com/weeth/domain/file/domain/service/FileDeleteService.java b/src/main/java/com/weeth/domain/file/domain/service/FileDeleteService.java deleted file mode 100644 index 9cb1168c..00000000 --- a/src/main/java/com/weeth/domain/file/domain/service/FileDeleteService.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.weeth.domain.file.domain.service; - -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.repository.FileRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class FileDeleteService { - - private final FileRepository fileRepository; - - public void delete(File file) { - fileRepository.delete(file); - } - - public void delete(List files) { - fileRepository.deleteAll(files); - } -} diff --git a/src/main/java/com/weeth/domain/file/domain/service/FileGetService.java b/src/main/java/com/weeth/domain/file/domain/service/FileGetService.java deleted file mode 100644 index c145ea49..00000000 --- a/src/main/java/com/weeth/domain/file/domain/service/FileGetService.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.weeth.domain.file.domain.service; - -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.repository.FileRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class FileGetService { - - private final FileRepository fileRepository; - - public List findAllByPost(Long postId) { - return fileRepository.findAllByPostId(postId); - } - - public List findAllByNotice(Long noticeId) { - return fileRepository.findAllByNoticeId(noticeId); - } - - public List findAllByReceipt(Long receiptId) { - return fileRepository.findAllByReceiptId(receiptId); - } - - public List findAllByComment(Long commentId) { - return fileRepository.findAllByCommentId(commentId); - } -} diff --git a/src/main/java/com/weeth/domain/file/domain/service/FileSaveService.java b/src/main/java/com/weeth/domain/file/domain/service/FileSaveService.java deleted file mode 100644 index 8b4cfffa..00000000 --- a/src/main/java/com/weeth/domain/file/domain/service/FileSaveService.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.file.domain.service; - -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.repository.FileRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class FileSaveService { - private final FileRepository fileRepository; - - public void save(File file) { - fileRepository.save(file); - } - - public void save(List files) { - fileRepository.saveAll(files); - } -} diff --git a/src/main/java/com/weeth/domain/file/domain/service/PreSignedService.java b/src/main/java/com/weeth/domain/file/domain/service/PreSignedService.java deleted file mode 100644 index 62c0c7c3..00000000 --- a/src/main/java/com/weeth/domain/file/domain/service/PreSignedService.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.weeth.domain.file.domain.service; - -import com.weeth.domain.file.application.dto.response.UrlResponse; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.global.config.properties.AwsS3Properties; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; - -import java.time.Duration; -import java.util.UUID; - -@Service -@RequiredArgsConstructor -public class PreSignedService { - - private final S3Presigner s3Presigner; - private final FileMapper fileMapper; - private final AwsS3Properties awsS3Properties; - - public UrlResponse generateUrl(String fileName) { - String key = generateKey(fileName); - - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(awsS3Properties.getS3().getBucket()) - .key(key) - .build(); - - PutObjectPresignRequest request = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(5)) - .putObjectRequest(putObjectRequest) - .build(); - - PresignedPutObjectRequest presignedUrlRequest = s3Presigner.presignPutObject(request); - - String putUrl = presignedUrlRequest.url().toString(); - - return fileMapper.toUrlResponse(fileName, putUrl); - } - - // 파일 이름을 고유하게 생성하는 메서드(확장자 포함) - private String generateKey(String fileName) { - String key = UUID.randomUUID().toString(); - String extension = fileName.substring(fileName.lastIndexOf(".") + 1); - - return key + "." + extension; - } -} diff --git a/src/main/java/com/weeth/domain/file/presentation/FileController.java b/src/main/java/com/weeth/domain/file/presentation/FileController.java deleted file mode 100644 index ad7ad701..00000000 --- a/src/main/java/com/weeth/domain/file/presentation/FileController.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.weeth.domain.file.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.file.application.dto.response.UrlResponse; -import com.weeth.domain.file.application.usecase.FileManageUseCase; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; - -@Tag(name = "FILE") -@RestController -@RequiredArgsConstructor -@RequestMapping("/files") -public class FileController { - - private final FileManageUseCase fileManageUseCase; - - @GetMapping("/") - @Operation(summary = "파일 업로드를 위한 presigned url을 요청하는 API 입니다.") - public CommonResponse> getUrl(@RequestParam(required = false) List fileName) { - return CommonResponse.success(FileResponseCode.PRESIGNED_URL_GET_SUCCESS, fileManageUseCase.getUrl(fileName)); - } -} diff --git a/src/main/java/com/weeth/domain/file/presentation/FileResponseCode.java b/src/main/java/com/weeth/domain/file/presentation/FileResponseCode.java deleted file mode 100644 index 93058d56..00000000 --- a/src/main/java/com/weeth/domain/file/presentation/FileResponseCode.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.file.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum FileResponseCode implements ResponseCodeInterface { - - PRESIGNED_URL_GET_SUCCESS(1500, HttpStatus.OK, "Presigned Url 반환에 성공했습니다"); - - private final int code; - private final HttpStatus status; - private final String message; - - FileResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/application/dto/PenaltyDTO.java b/src/main/java/com/weeth/domain/penalty/application/dto/PenaltyDTO.java deleted file mode 100644 index 0284ceb9..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/dto/PenaltyDTO.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.weeth.domain.penalty.application.dto; - -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public class PenaltyDTO { - - @Builder - public record Save( - @NotNull Long userId, - @NotNull PenaltyType penaltyType, - String penaltyDescription - ){} - - @Builder - public record Update( - @NotNull Long penaltyId, - String penaltyDescription - ){} - - @Builder - public record ResponseAll( - Integer cardinal, - List responses - ){} - - @Builder - public record Response( - Long userId, - Integer penaltyCount, - Integer warningCount, - String name, - List cardinals, - List Penalties - ){} - - @Builder - public record Penalties( - Long penaltyId, - PenaltyType penaltyType, - Integer cardinal, - String penaltyDescription, - LocalDateTime time - ){} - -} - diff --git a/src/main/java/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.java b/src/main/java/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.java deleted file mode 100644 index 6ff34a58..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.penalty.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AutoPenaltyDeleteNotAllowedException extends BaseException { - public AutoPenaltyDeleteNotAllowedException() { - super(PenaltyErrorCode.AUTO_PENALTY_DELETE_NOT_ALLOWED); - } -} diff --git a/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.java b/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.java deleted file mode 100644 index f5a341a1..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.penalty.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum PenaltyErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 패널티 ID가 존재하지 않을 때 발생합니다.") - PENALTY_NOT_FOUND(2600, HttpStatus.NOT_FOUND, "존재하지 않는 패널티입니다."), - - @ExplainError("시스템에 의해 자동 부여된 패널티를 수동으로 삭제하려 할 때 발생합니다.") - AUTO_PENALTY_DELETE_NOT_ALLOWED(2601, HttpStatus.BAD_REQUEST, "자동 생성된 패널티는 삭제할 수 없습니다"); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.java b/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.java deleted file mode 100644 index cceb0ad6..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.penalty.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class PenaltyNotFoundException extends BaseException { - public PenaltyNotFoundException() { - super(PenaltyErrorCode.PENALTY_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/penalty/application/mapper/PenaltyMapper.java b/src/main/java/com/weeth/domain/penalty/application/mapper/PenaltyMapper.java deleted file mode 100644 index 06544027..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/mapper/PenaltyMapper.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.weeth.domain.penalty.application.mapper; - -import com.weeth.domain.penalty.application.dto.PenaltyDTO; -import com.weeth.domain.penalty.domain.entity.Penalty; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.MappingConstants; -import org.mapstruct.ReportingPolicy; - -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface PenaltyMapper { - - @Mapping(target = "user", source = "user") - @Mapping(target = "cardinal", source = "cardinal") - @Mapping(target = "id", ignore = true) - @Mapping(target = "createdAt", ignore = true) - @Mapping(target = "modifiedAt", ignore = true) - Penalty fromPenaltyDto(PenaltyDTO.Save dto, User user, Cardinal cardinal); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "createdAt", ignore = true) - @Mapping(target = "modifiedAt", ignore = true) - Penalty toAutoPenalty(String penaltyDescription, User user, Cardinal cardinal, PenaltyType penaltyType); - - @Mapping(target = "Penalties", source = "penalties") - @Mapping(target = "userId", source = "user.id") - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - PenaltyDTO.Response toPenaltyDto(User user, List penalties, List userCardinals); - - @Mapping(target = "time", source = "modifiedAt") - @Mapping(target = "penaltyId", source = "id") - @Mapping(target = "cardinal", - expression = "java(penalty.getCardinal() != null ? penalty.getCardinal().getCardinalNumber() : null)") - - PenaltyDTO.Penalties toPenalties(Penalty penalty); - - PenaltyDTO.ResponseAll toResponseAll(Integer cardinal, List responses); - - default List toCardinalNumbers(List userCardinals) { - if (userCardinals == null || userCardinals.isEmpty()) { - return Collections.emptyList(); - } - - return userCardinals.stream() - .map(uc -> uc.getCardinal().getCardinalNumber()) - .collect(Collectors.toList()); - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecase.java b/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecase.java deleted file mode 100644 index 3d30b4ca..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecase.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.penalty.application.usecase; - -import com.weeth.domain.penalty.application.dto.PenaltyDTO; - -import java.util.List; - -public interface PenaltyUsecase { - - void save(PenaltyDTO.Save dto); - - void update(PenaltyDTO.Update dto); - - List findAll(Integer cardinalNumber); - - PenaltyDTO.Response find(Long userId); - - void delete(Long penaltyId); - -} diff --git a/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecaseImpl.java b/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecaseImpl.java deleted file mode 100644 index ee906d6d..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecaseImpl.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.weeth.domain.penalty.application.usecase; - -import jakarta.transaction.Transactional; -import com.weeth.domain.penalty.application.dto.PenaltyDTO; -import com.weeth.domain.penalty.application.exception.AutoPenaltyDeleteNotAllowedException; -import com.weeth.domain.penalty.application.mapper.PenaltyMapper; -import com.weeth.domain.penalty.domain.entity.Penalty; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import com.weeth.domain.penalty.domain.service.PenaltyDeleteService; -import com.weeth.domain.penalty.domain.service.PenaltyFindService; -import com.weeth.domain.penalty.domain.service.PenaltySaveService; -import com.weeth.domain.penalty.domain.service.PenaltyUpdateService; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.service.CardinalGetService; -import com.weeth.domain.user.domain.service.UserCardinalGetService; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class PenaltyUsecaseImpl implements PenaltyUsecase{ - - private static final String AUTO_PENALTY_DESCRIPTION = "누적경고 %d회"; - - private final PenaltySaveService penaltySaveService; - private final PenaltyFindService penaltyFindService; - private final PenaltyUpdateService penaltyUpdateService; - private final PenaltyDeleteService penaltyDeleteService; - - private final UserGetService userGetService; - - private final UserCardinalGetService userCardinalGetService; - private final CardinalGetService cardinalGetService; - - private final PenaltyMapper mapper; - - @Override - @Transactional - public void save(PenaltyDTO.Save dto) { - User user = userGetService.find(dto.userId()); - Cardinal cardinal = userCardinalGetService.getCurrentCardinal(user); - - Penalty penalty = mapper.fromPenaltyDto(dto, user, cardinal); - - penaltySaveService.save(penalty); - - if(penalty.getPenaltyType().equals(PenaltyType.PENALTY)){ - user.incrementPenaltyCount(); - } else if (penalty.getPenaltyType().equals(PenaltyType.WARNING)){ - user.incrementWarningCount(); - - Integer warningCount = user.getWarningCount(); - if(warningCount % 2 == 0){ - String penaltyDescription = String.format(AUTO_PENALTY_DESCRIPTION, warningCount); - Penalty autoPenalty = mapper.toAutoPenalty(penaltyDescription, user, cardinal, PenaltyType.AUTO_PENALTY); - penaltySaveService.save(autoPenalty); - user.incrementPenaltyCount(); - } - } - } - - @Override - @Transactional - public void update(PenaltyDTO.Update dto) { - Penalty penalty = penaltyFindService.find(dto.penaltyId()); - penaltyUpdateService.update(penalty, dto); - - } - - // Todo: 쿼리 최적화 필요 - @Override - public List findAll(Integer cardinalNumber) { - List cardinals = (cardinalNumber == null) - ? cardinalGetService.findAllCardinalNumberDesc() - : List.of(cardinalGetService.findByAdminSide(cardinalNumber)); - - List result = new ArrayList<>(); - - for (Cardinal cardinal : cardinals) { - List penalties = penaltyFindService.findAllByCardinalId(cardinal.getId()); - - Map> penaltiesByUser = penalties.stream() - .collect(Collectors.groupingBy(p -> p.getUser().getId())); - - List responses = penaltiesByUser.entrySet().stream() - .map(entry -> toPenaltyDto(entry.getKey(), entry.getValue())) - .sorted(Comparator.comparing(PenaltyDTO.Response::userId)) - .toList(); - - result.add(mapper.toResponseAll(cardinal.getCardinalNumber(), responses)); - } - return result; - } - - @Override - public PenaltyDTO.Response find(Long userId) { - User user = userGetService.find(userId); - Cardinal currentCardinal = userCardinalGetService.getCurrentCardinal(user); - List penalties = penaltyFindService.findAllByUserIdAndCardinalId(userId, currentCardinal.getId()); - - return toPenaltyDto(userId, penalties); - } - - @Override - @Transactional - public void delete(Long penaltyId) { - Penalty penalty = penaltyFindService.find(penaltyId); - if(penalty.getPenaltyType().equals(PenaltyType.AUTO_PENALTY)){ - throw new AutoPenaltyDeleteNotAllowedException(); - } - - User user = penalty.getUser(); - - if(penalty.getPenaltyType().equals(PenaltyType.PENALTY)){ - penalty.getUser().decrementPenaltyCount(); - } else if (penalty.getPenaltyType().equals(PenaltyType.WARNING)) { - if(user.getWarningCount() % 2 == 0){ - Penalty relatedAutoPenalty = penaltyFindService.getRelatedAutoPenalty(penalty); - if(relatedAutoPenalty != null){ - penaltyDeleteService.delete(relatedAutoPenalty.getId()); - } - user.decrementPenaltyCount(); - } - penalty.getUser().decrementWarningCount(); - } - - penaltyDeleteService.delete(penaltyId); - } - - private PenaltyDTO.Response toPenaltyDto(Long userId, List penalties) { - User user = userGetService.find(userId); - List userCardinals = userCardinalGetService.getUserCardinals(user); - - List penaltyDTOs = penalties.stream() - .map(mapper::toPenalties) - .toList(); - - return mapper.toPenaltyDto(user, penaltyDTOs, userCardinals); - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/entity/Penalty.java b/src/main/java/com/weeth/domain/penalty/domain/entity/Penalty.java deleted file mode 100644 index 67c82810..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/entity/Penalty.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.weeth.domain.penalty.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Penalty extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "penalty_id") - private Long id; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - @ManyToOne - @JoinColumn(name = "cardinal_id") - private Cardinal cardinal; - - @Enumerated(EnumType.STRING) - private PenaltyType penaltyType; - - private String penaltyDescription; - - public void update(String penaltyDescription) { - this.penaltyDescription = penaltyDescription; - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/entity/enums/PenaltyType.java b/src/main/java/com/weeth/domain/penalty/domain/entity/enums/PenaltyType.java deleted file mode 100644 index 44031768..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/entity/enums/PenaltyType.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.penalty.domain.entity.enums; - -public enum PenaltyType { - PENALTY, - AUTO_PENALTY, - WARNING -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/repository/PenaltyRepository.java b/src/main/java/com/weeth/domain/penalty/domain/repository/PenaltyRepository.java deleted file mode 100644 index 95d14c91..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/repository/PenaltyRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.weeth.domain.penalty.domain.repository; - -import com.weeth.domain.penalty.domain.entity.Penalty; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -public interface PenaltyRepository extends JpaRepository { - - List findByUserIdAndCardinalIdOrderByIdDesc(Long userId, Long cardinalId); - - Optional findFirstByUserAndCardinalAndPenaltyTypeAndCreatedAtAfterOrderByCreatedAtAsc( - User user, Cardinal cardinal, PenaltyType penaltyType, LocalDateTime createdAt); - - List findByCardinalIdOrderByIdDesc(Long cardinalId); -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyDeleteService.java b/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyDeleteService.java deleted file mode 100644 index 457e239e..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyDeleteService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.penalty.domain.service; - -import com.weeth.domain.penalty.domain.repository.PenaltyRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PenaltyDeleteService { - - private final PenaltyRepository penaltyRepository; - - public void delete(Long penaltyId){ - penaltyRepository.deleteById(penaltyId); - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyFindService.java b/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyFindService.java deleted file mode 100644 index 7972de54..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyFindService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.weeth.domain.penalty.domain.service; - -import com.weeth.domain.penalty.domain.entity.Penalty; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import com.weeth.domain.penalty.domain.repository.PenaltyRepository; -import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class PenaltyFindService { - - private final PenaltyRepository penaltyRepository; - - public Penalty find(Long penaltyId){ - return penaltyRepository.findById(penaltyId) - .orElseThrow(PenaltyNotFoundException::new); - } - - public List findAllByUserIdAndCardinalId(Long userId, Long cardinalId){ - return penaltyRepository.findByUserIdAndCardinalIdOrderByIdDesc(userId, cardinalId); - } - - public List findAll(){ - return penaltyRepository.findAll(); - } - - public Penalty getRelatedAutoPenalty(Penalty penalty) { - return penaltyRepository - .findFirstByUserAndCardinalAndPenaltyTypeAndCreatedAtAfterOrderByCreatedAtAsc( - penalty.getUser(), - penalty.getCardinal(), - PenaltyType.AUTO_PENALTY, - penalty.getCreatedAt() - ).orElse(null); - } - - public List findAllByCardinalId(Long cardinalId) { - return penaltyRepository.findByCardinalIdOrderByIdDesc(cardinalId); - } -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltySaveService.java b/src/main/java/com/weeth/domain/penalty/domain/service/PenaltySaveService.java deleted file mode 100644 index 9b40dff7..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltySaveService.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.penalty.domain.service; - -import com.weeth.domain.penalty.domain.entity.Penalty; -import com.weeth.domain.penalty.domain.repository.PenaltyRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PenaltySaveService { - - private final PenaltyRepository penaltyRepository; - - public void save(Penalty penalty){ - penaltyRepository.save(penalty); - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyUpdateService.java b/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyUpdateService.java deleted file mode 100644 index a4148162..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyUpdateService.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.weeth.domain.penalty.domain.service; - -import com.weeth.domain.penalty.application.dto.PenaltyDTO; -import com.weeth.domain.penalty.domain.entity.Penalty; -import org.springframework.stereotype.Service; - -@Service -public class PenaltyUpdateService { - - public void update(Penalty penalty, PenaltyDTO.Update dto) { - if (dto.penaltyDescription() != null && !dto.penaltyDescription().isBlank()) { - penalty.update(dto.penaltyDescription()); - } - } -} diff --git a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyAdminController.java b/src/main/java/com/weeth/domain/penalty/presentation/PenaltyAdminController.java deleted file mode 100644 index ed1adb92..00000000 --- a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyAdminController.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.weeth.domain.penalty.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.penalty.application.dto.PenaltyDTO; -import com.weeth.domain.penalty.application.exception.PenaltyErrorCode; -import com.weeth.domain.penalty.application.usecase.PenaltyUsecase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static com.weeth.domain.penalty.presentation.PenaltyResponseCode.*; - -@Tag(name = "PENALTY ADMIN", description = "[ADMIN] 패널티 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/penalties") -@ApiErrorCodeExample(PenaltyErrorCode.class) -public class PenaltyAdminController { - - private final PenaltyUsecase penaltyUsecase; - - @PostMapping - @Operation(summary="패널티 부여") - public CommonResponse assignPenalty(@Valid @RequestBody PenaltyDTO.Save dto){ - penaltyUsecase.save(dto); - return CommonResponse.success(PENALTY_ASSIGN_SUCCESS); - } - - @PatchMapping - @Operation(summary = "패널티 수정") - public CommonResponse update(@Valid @RequestBody PenaltyDTO.Update dto){ - penaltyUsecase.update(dto); - return CommonResponse.success(PENALTY_UPDATE_SUCCESS); - } - - @GetMapping - @Operation(summary="전체 패널티 조회") - public CommonResponse> findAll(@RequestParam(required = false) Integer cardinal){ - return CommonResponse.success(PENALTY_FIND_ALL_SUCCESS, penaltyUsecase.findAll(cardinal)); - } - - @DeleteMapping - @Operation(summary="패널티 삭제") - public CommonResponse delete(@RequestParam Long penaltyId){ - penaltyUsecase.delete(penaltyId); - return CommonResponse.success(PENALTY_DELETE_SUCCESS); - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyResponseCode.java b/src/main/java/com/weeth/domain/penalty/presentation/PenaltyResponseCode.java deleted file mode 100644 index e73bdca4..00000000 --- a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyResponseCode.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.weeth.domain.penalty.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum PenaltyResponseCode implements ResponseCodeInterface { - // penaltyAdminController 관련 - PENALTY_ASSIGN_SUCCESS(1600, HttpStatus.OK, "페널티가 성공적으로 부여되었습니다."), - PENALTY_FIND_ALL_SUCCESS(1601, HttpStatus.OK, "모든 패널티가 성공적으로 조회되었습니다."), - PENALTY_DELETE_SUCCESS(1602, HttpStatus.OK, "패널티가 성공적으로 삭제되었습니다."), - PENALTY_UPDATE_SUCCESS(1603, HttpStatus.OK, "패널티를 성공적으로 수정했습니다."), - // penaltyUserController - PENALTY_USER_FIND_SUCCESS(1604, HttpStatus.OK, "패널티가 성공적으로 조회되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - PenaltyResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyUserController.java b/src/main/java/com/weeth/domain/penalty/presentation/PenaltyUserController.java deleted file mode 100644 index aed2feea..00000000 --- a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyUserController.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.weeth.domain.penalty.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.penalty.application.dto.PenaltyDTO; -import com.weeth.domain.penalty.application.exception.PenaltyErrorCode; -import com.weeth.domain.penalty.application.usecase.PenaltyUsecase; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.penalty.presentation.PenaltyResponseCode.PENALTY_USER_FIND_SUCCESS; - -@Tag(name = "PENALTY", description = "패널티 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/penalties") -@ApiErrorCodeExample(PenaltyErrorCode.class) -public class PenaltyUserController { - - private final PenaltyUsecase penaltyUsecase; - - @GetMapping - @Operation(summary="본인 패널티 조회") - public CommonResponse findAllPenalties(@Parameter(hidden = true) @CurrentUser Long userId) { - PenaltyDTO.Response penalties = penaltyUsecase.find(userId); - return CommonResponse.success(PENALTY_USER_FIND_SUCCESS,penalties); - } - -} diff --git a/src/main/java/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.java b/src/main/java/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.java deleted file mode 100644 index 7e8ec584..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.weeth.domain.schedule.application.annotation; - -import jakarta.validation.Constraint; -import jakarta.validation.Payload; -import com.weeth.domain.schedule.application.validator.ScheduleTimeCheckValidator; - -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -@Target({FIELD}) -@Retention(RUNTIME) -@Constraint(validatedBy = ScheduleTimeCheckValidator.class) -public @interface ScheduleTimeCheck { - - String message() default "마감 시간이 시작 시간보다 빠를 수 없습니다."; - - Class[] groups() default {}; - - Class[] payload() default {}; - -} diff --git a/src/main/java/com/weeth/domain/schedule/application/dto/EventDTO.java b/src/main/java/com/weeth/domain/schedule/application/dto/EventDTO.java deleted file mode 100644 index ee949d3a..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/dto/EventDTO.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.weeth.domain.schedule.application.dto; - -import com.weeth.domain.schedule.domain.entity.enums.Type; - -import java.time.LocalDateTime; - -public class EventDTO { - - public record Response( - Long id, - String title, - String content, - String location, - String requiredItem, - String name, - Integer cardinal, - Type type, - LocalDateTime start, - LocalDateTime end, - LocalDateTime createdAt, - LocalDateTime modifiedAt - ) {} -} - diff --git a/src/main/java/com/weeth/domain/schedule/application/dto/MeetingDTO.java b/src/main/java/com/weeth/domain/schedule/application/dto/MeetingDTO.java deleted file mode 100644 index 09b89017..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/dto/MeetingDTO.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.weeth.domain.schedule.application.dto; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.weeth.domain.schedule.domain.entity.enums.Type; - -import java.time.LocalDateTime; -import java.util.List; - -public class MeetingDTO { - - @JsonInclude(JsonInclude.Include.NON_NULL) - public record Response( - Long id, - String title, - String content, - String location, - String requiredItem, - String name, - Integer cardinal, - Type type, - Integer code, - LocalDateTime start, - LocalDateTime end, - LocalDateTime createdAt, - LocalDateTime modifiedAt - ) {} - - public record Info( - Long id, - Integer cardinal, - String title, - LocalDateTime start - ) {} - - public record Infos( - Info thisWeek, - List meetings - ) {} - - -} diff --git a/src/main/java/com/weeth/domain/schedule/application/dto/ScheduleDTO.java b/src/main/java/com/weeth/domain/schedule/application/dto/ScheduleDTO.java deleted file mode 100644 index 0aecfa05..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/dto/ScheduleDTO.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.weeth.domain.schedule.application.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.schedule.domain.entity.enums.Type; -import org.springframework.format.annotation.DateTimeFormat; - -import java.time.LocalDateTime; - -public class ScheduleDTO { - - public record Response( - Long id, - String title, - LocalDateTime start, - LocalDateTime end, - Boolean isMeeting - ) {} - - public record Time( - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end - ) {} - - public record Save( - @NotBlank String title, - @NotBlank String content, - @NotBlank String location, - String requiredItem, - @NotNull Type type, - @NotNull Integer cardinal, - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end - ) {} - - public record Update( - @NotBlank String title, - @NotBlank String content, - @NotBlank String location, - String requiredItem, - @NotNull Type type, - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end - ) {} -} diff --git a/src/main/java/com/weeth/domain/schedule/application/exception/EventErrorCode.java b/src/main/java/com/weeth/domain/schedule/application/exception/EventErrorCode.java deleted file mode 100644 index 591dbaca..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/exception/EventErrorCode.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.schedule.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum EventErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 일정 ID에 해당하는 일정이 존재하지 않을 때 발생합니다.") - EVENT_NOT_FOUND(2700, HttpStatus.NOT_FOUND, "존재하지 않는 일정입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/schedule/application/exception/EventNotFoundException.java b/src/main/java/com/weeth/domain/schedule/application/exception/EventNotFoundException.java deleted file mode 100644 index 48856ea5..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/exception/EventNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.schedule.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class EventNotFoundException extends BaseException { - public EventNotFoundException() { - super(EventErrorCode.EVENT_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/application/exception/MeetingErrorCode.java b/src/main/java/com/weeth/domain/schedule/application/exception/MeetingErrorCode.java deleted file mode 100644 index ad96ff71..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/exception/MeetingErrorCode.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.schedule.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum MeetingErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 정기모임 ID에 해당하는 정기모임이 존재하지 않을 때 발생합니다.") - MEETING_NOT_FOUND(2701, HttpStatus.NOT_FOUND, "존재하지 않는 정기모임입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/schedule/application/exception/MeetingNotFoundException.java b/src/main/java/com/weeth/domain/schedule/application/exception/MeetingNotFoundException.java deleted file mode 100644 index d9b4d4d7..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/exception/MeetingNotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.schedule.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class MeetingNotFoundException extends BaseException { - public MeetingNotFoundException() {super(MeetingErrorCode.MEETING_NOT_FOUND);} -} diff --git a/src/main/java/com/weeth/domain/schedule/application/mapper/EventMapper.java b/src/main/java/com/weeth/domain/schedule/application/mapper/EventMapper.java deleted file mode 100644 index c297891c..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/mapper/EventMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.schedule.application.mapper; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import static com.weeth.domain.schedule.application.dto.EventDTO.Response; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface EventMapper { - - @Mapping(target = "name", source = "event.user.name") - @Mapping(target = "type", expression = "java(Type.EVENT)") - Response to(Event event); - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "user", source = "user") - }) - Event from(ScheduleDTO.Save dto, User user); -} diff --git a/src/main/java/com/weeth/domain/schedule/application/mapper/MeetingMapper.java b/src/main/java/com/weeth/domain/schedule/application/mapper/MeetingMapper.java deleted file mode 100644 index c81c72bc..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/mapper/MeetingMapper.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.weeth.domain.schedule.application.mapper; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.Random; - -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Info; -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Response; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface MeetingMapper { - - @Mapping(target = "name", source = "user.name") - @Mapping(target = "code", ignore = true) - @Mapping(target = "type", expression = "java(Type.MEETING)") - Response to(Meeting meeting); - - Info toInfo(Meeting meeting); - - @Mapping(target = "name", source = "user.name") - @Mapping(target = "type", expression = "java(Type.MEETING)") - Response toAdminResponse(Meeting meeting); - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "code", expression = "java( generateCode() )"), - @Mapping(target = "user", source = "user") - }) - Meeting from(ScheduleDTO.Save dto, User user); - - default Integer generateCode() { - return new Random().nextInt(9000) + 1000; - } - - /* - 차후 필히 리팩토링 할 것 - -> 정기 모임의 참여하는 인원의 멤버수를 어떻게 관리할지. - 해당 코드는 일시적인 대안책임 - */ -// default Integer getMemberCount(Meeting meeting) { -// return (int)meeting.getAttendances().stream() -// .filter(attendance -> !attendance.getUser().getStatus().equals(Status.BANNED)) -// .filter(attendance -> !attendance.getUser().getStatus().equals(Status.LEFT)) -// .count(); -// } -} diff --git a/src/main/java/com/weeth/domain/schedule/application/mapper/ScheduleMapper.java b/src/main/java/com/weeth/domain/schedule/application/mapper/ScheduleMapper.java deleted file mode 100644 index c61299bd..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/mapper/ScheduleMapper.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.domain.schedule.application.mapper; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.Schedule; -import org.mapstruct.Mapper; -import org.mapstruct.MappingConstants; -import org.mapstruct.ReportingPolicy; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface ScheduleMapper { - - ScheduleDTO.Response toScheduleDTO(Schedule schedule, Boolean isMeeting); -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCase.java b/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCase.java deleted file mode 100644 index e0227af7..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCase.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; - -import static com.weeth.domain.schedule.application.dto.EventDTO.*; - -public interface EventUseCase { - - Response find(Long eventId); - - void save(ScheduleDTO.Save dto, Long userId); - - void update(Long eventId, ScheduleDTO.Update dto, Long userId); - - void delete(Long eventId); -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCaseImpl.java b/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCaseImpl.java deleted file mode 100644 index 62ada147..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCaseImpl.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.application.mapper.EventMapper; -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.schedule.domain.service.EventDeleteService; -import com.weeth.domain.schedule.domain.service.EventGetService; -import com.weeth.domain.schedule.domain.service.EventSaveService; -import com.weeth.domain.schedule.domain.service.EventUpdateService; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.service.CardinalGetService; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import static com.weeth.domain.schedule.application.dto.EventDTO.Response; - -@Service -@RequiredArgsConstructor -public class EventUseCaseImpl implements EventUseCase { - - private final UserGetService userGetService; - private final EventGetService eventGetService; - private final EventSaveService eventSaveService; - private final EventUpdateService eventUpdateService; - private final EventDeleteService eventDeleteService; - private final CardinalGetService cardinalGetService; - private final EventMapper mapper; - - @Override - public Response find(Long eventId) { - return mapper.to(eventGetService.find(eventId)); - } - - @Override - @Transactional - public void save(ScheduleDTO.Save dto, Long userId) { - User user = userGetService.find(userId); - cardinalGetService.findByUserSide(dto.cardinal()); - - eventSaveService.save(mapper.from(dto, user)); - } - - @Override - @Transactional - public void update(Long eventId, ScheduleDTO.Update dto, Long userId) { - User user = userGetService.find(userId); - Event event = eventGetService.find(eventId); - eventUpdateService.update(event, dto, user); - } - - @Override - @Transactional - public void delete(Long eventId) { - Event event = eventGetService.find(eventId); - eventDeleteService.delete(event); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCase.java b/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCase.java deleted file mode 100644 index 857de980..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCase.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import com.weeth.domain.schedule.application.dto.MeetingDTO; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; - -import java.util.List; - -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Info; -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Response; - -public interface MeetingUseCase { - - Response find(Long userId, Long eventId); - - MeetingDTO.Infos find(Integer cardinal); - - void save(ScheduleDTO.Save dto, Long userId); - - void update(ScheduleDTO.Update dto, Long userId, Long meetingId); - - void delete(Long meetingId); -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java b/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java deleted file mode 100644 index 21a25151..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.service.AttendanceDeleteService; -import com.weeth.domain.attendance.domain.service.AttendanceGetService; -import com.weeth.domain.attendance.domain.service.AttendanceSaveService; -import com.weeth.domain.attendance.domain.service.AttendanceUpdateService; -import com.weeth.domain.schedule.application.dto.MeetingDTO; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.application.mapper.MeetingMapper; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.service.MeetingDeleteService; -import com.weeth.domain.schedule.domain.service.MeetingGetService; -import com.weeth.domain.schedule.domain.service.MeetingSaveService; -import com.weeth.domain.schedule.domain.service.MeetingUpdateService; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.service.CardinalGetService; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.DayOfWeek; -import java.time.LocalDate; -import java.time.temporal.TemporalAdjusters; -import java.util.Comparator; -import java.util.List; - -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Info; -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Response; - -@Slf4j -@Service -@RequiredArgsConstructor -public class MeetingUseCaseImpl implements MeetingUseCase { - - private final MeetingGetService meetingGetService; - private final MeetingMapper mapper; - private final MeetingSaveService meetingSaveService; - private final UserGetService userGetService; - private final MeetingUpdateService meetingUpdateService; - private final MeetingDeleteService meetingDeleteService; - private final AttendanceGetService attendanceGetService; - private final AttendanceSaveService attendanceSaveService; - private final AttendanceDeleteService attendanceDeleteService; - private final AttendanceUpdateService attendanceUpdateService; - private final CardinalGetService cardinalGetService; - - @PersistenceContext - private EntityManager em; - - @Override - public Response find(Long userId, Long meetingId) { - User user = userGetService.find(userId); - Meeting meeting = meetingGetService.find(meetingId); - - if (Role.ADMIN == user.getRole()) { - return mapper.toAdminResponse(meeting) ; - } - - return mapper.to(meeting); - } - - @Override - public MeetingDTO.Infos find(Integer cardinal) { - List meetings; - - if (cardinal == null) { - meetings = meetingGetService.findAll(); - } else { - meetings = meetingGetService.findMeetingByCardinal(cardinal); - } - - Meeting thisWeek = findThisWeek(meetings); - List sorted = sortMeetings(meetings); - - return new MeetingDTO.Infos( - thisWeek != null ? mapper.toInfo(thisWeek) : null, - sorted.stream().map(mapper::toInfo).toList()); - } - - @Override - @Transactional - public void save(ScheduleDTO.Save dto, Long userId) { - User user = userGetService.find(userId); - Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); - - List userList = userGetService.findAllByCardinal(cardinal); - - Meeting meeting = mapper.from(dto, user); - meetingSaveService.save(meeting); - - attendanceSaveService.saveAll(userList, meeting); - } - - @Override - @Transactional - public void update(ScheduleDTO.Update dto, Long userId, Long meetingId) { - Meeting meeting = meetingGetService.find(meetingId); - User user = userGetService.find(userId); - meetingUpdateService.update(dto, user, meeting); - } - - @Override - @Transactional - public void delete(Long meetingId) { - Meeting meeting = meetingGetService.find(meetingId); - List attendances = attendanceGetService.findAllByMeeting(meeting); - - attendanceUpdateService.updateUserAttendanceByStatus(attendances); - - em.flush(); - em.clear(); - - attendanceDeleteService.deleteAll(meeting); - meetingDeleteService.delete(meeting); - } - - private List sortMeetings(List meetings) { - return meetings.stream() - .sorted(Comparator.comparing(Meeting::getStart).reversed()) - .toList(); - } - - - private Meeting findThisWeek(List meetings) { - LocalDate today = LocalDate.now(); - LocalDate startOfWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - LocalDate endOfWeek = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); - - return meetings.stream() - .filter(m -> { - LocalDate d = m.getStart().toLocalDate(); - return !d.isBefore(startOfWeek) && !d.isAfter(endOfWeek); - }) - .findFirst() - .orElse(null); - } - -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCase.java b/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCase.java deleted file mode 100644 index 4676a026..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCase.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -import static com.weeth.domain.schedule.application.dto.ScheduleDTO.Response; - -public interface ScheduleUseCase { - - List findByMonthly(LocalDateTime start, LocalDateTime end); - - Map> findByYearly(Integer year, Integer semester); - -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCaseImpl.java b/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCaseImpl.java deleted file mode 100644 index c7818d1d..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCaseImpl.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import com.weeth.domain.schedule.domain.service.EventGetService; -import com.weeth.domain.schedule.domain.service.MeetingGetService; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.service.CardinalGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import java.util.stream.Stream; - -import static com.weeth.domain.schedule.application.dto.ScheduleDTO.Response; - -@Service -@RequiredArgsConstructor -public class ScheduleUseCaseImpl implements ScheduleUseCase { - - private final EventGetService eventGetService; - private final MeetingGetService meetingGetService; - private final CardinalGetService cardinalGetService; - - @Override - public List findByMonthly(LocalDateTime start, LocalDateTime end) { - List events = eventGetService.find(start, end); - List meetings = meetingGetService.find(start, end); - - return Stream.of(events, meetings) - .flatMap(Collection::stream) - .sorted(Comparator.comparing(Response::start)) - .toList(); - } - - @Override - public Map> findByYearly(Integer year, Integer semester) { - Cardinal cardinal = cardinalGetService.find(year, semester); - - List events = eventGetService.find(cardinal.getCardinalNumber()); - List meetings = meetingGetService.findByCardinal(cardinal.getCardinalNumber()); - - return Stream.of(events, meetings) - .flatMap(Collection::stream) // 병합 - .sorted(Comparator.comparing(Response::start)) // 스케줄 시작 시간으로 정렬 - .flatMap(schedule -> { - List> monthEventPairs = new ArrayList<>(); - - int left = schedule.start().getMonthValue(); - int right = schedule.end().getMonthValue() + 1; - IntStream.range(left, right) // 기간 내 포함된 달 계산 - .forEach(month -> monthEventPairs.add( - new AbstractMap.SimpleEntry<>(month, schedule)) - ); - - return monthEventPairs.stream(); - }) - .collect(Collectors.groupingBy( - Map.Entry::getKey, - Collectors.mapping(Map.Entry::getValue, Collectors.toList()) - )); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.java b/src/main/java/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.java deleted file mode 100644 index 62668e4b..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.schedule.application.validator; - -import jakarta.validation.ConstraintValidator; -import jakarta.validation.ConstraintValidatorContext; -import com.weeth.domain.schedule.application.annotation.ScheduleTimeCheck; -import com.weeth.domain.schedule.application.dto.ScheduleDTO.Time; - -public class ScheduleTimeCheckValidator implements ConstraintValidator { - - @Override - public void initialize(ScheduleTimeCheck constraintAnnotation) { - ConstraintValidator.super.initialize(constraintAnnotation); - } - - @Override - public boolean isValid(Time time, ConstraintValidatorContext context) { - return time.start().isBefore(time.end().plusMinutes(1)); - } -} \ No newline at end of file diff --git a/src/main/java/com/weeth/domain/schedule/domain/entity/Event.java b/src/main/java/com/weeth/domain/schedule/domain/entity/Event.java deleted file mode 100644 index b9b5253f..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/entity/Event.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.weeth.domain.schedule.domain.entity; - -import jakarta.persistence.Entity; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.user.domain.entity.User; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Event extends Schedule { - - public void update(ScheduleDTO.Update dto, User user) { - this.updateUpperClass(dto, user); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/entity/Meeting.java b/src/main/java/com/weeth/domain/schedule/domain/entity/Meeting.java deleted file mode 100644 index 30ad37fe..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/entity/Meeting.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.weeth.domain.schedule.domain.entity; - -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.PrePersist; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.enums.MeetingStatus; -import com.weeth.domain.user.domain.entity.User; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@SuperBuilder -public class Meeting extends Schedule { - - private Integer code; - - @Enumerated(EnumType.STRING) - private MeetingStatus meetingStatus; - - public void update(ScheduleDTO.Update dto, User user) { - this.updateUpperClass(dto, user); - } - - @PrePersist - public void init() { - this.meetingStatus = MeetingStatus.OPEN; - } - - public void close() { - this.meetingStatus = MeetingStatus.CLOSE; - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/entity/Schedule.java b/src/main/java/com/weeth/domain/schedule/domain/entity/Schedule.java deleted file mode 100644 index 3c232518..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/entity/Schedule.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.weeth.domain.schedule.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.LocalDateTime; - - -@Getter -@MappedSuperclass -@EntityListeners(AuditingEntityListener.class) -@SuperBuilder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Schedule extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String title; - - @Column(columnDefinition = "TEXT") - private String content; - - private String location; - - private Integer cardinal; - - private String requiredItem; - - private LocalDateTime start; - - private LocalDateTime end; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - public void updateUpperClass(ScheduleDTO.Update dto, User user) { - this.title = dto.title(); - this.content = dto.content(); - this.location = dto.location(); - this.requiredItem = dto.requiredItem(); - this.start = dto.start(); - this.end = dto.end(); - this.user = user; - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/entity/enums/MeetingStatus.java b/src/main/java/com/weeth/domain/schedule/domain/entity/enums/MeetingStatus.java deleted file mode 100644 index 7ca34280..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/entity/enums/MeetingStatus.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.weeth.domain.schedule.domain.entity.enums; - -public enum MeetingStatus { - OPEN, - CLOSE -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/entity/enums/Type.java b/src/main/java/com/weeth/domain/schedule/domain/entity/enums/Type.java deleted file mode 100644 index ca0c721d..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/entity/enums/Type.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.weeth.domain.schedule.domain.entity.enums; - -public enum Type { - EVENT, MEETING -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/repository/EventRepository.java b/src/main/java/com/weeth/domain/schedule/domain/repository/EventRepository.java deleted file mode 100644 index a281ed08..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/repository/EventRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.schedule.domain.repository; - -import com.weeth.domain.schedule.domain.entity.Event; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.time.LocalDateTime; -import java.util.List; - -public interface EventRepository extends JpaRepository { - - List findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(LocalDateTime end, LocalDateTime start); - - List findAllByCardinal(int cardinal); -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/repository/MeetingRepository.java b/src/main/java/com/weeth/domain/schedule/domain/repository/MeetingRepository.java deleted file mode 100644 index e85d650f..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/repository/MeetingRepository.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.weeth.domain.schedule.domain.repository; - -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.entity.enums.MeetingStatus; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.time.LocalDateTime; -import java.util.List; - -public interface MeetingRepository extends JpaRepository { - - List findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(LocalDateTime start, LocalDateTime end); - - List findAllByCardinalOrderByStartAsc(int cardinal); - - List findAllByCardinalOrderByStartDesc(int cardinal); - - List findAllByCardinal(int cardinal); - - List findAllByMeetingStatusAndEndBeforeOrderByEndAsc(MeetingStatus status, LocalDateTime end); - - List findAllByOrderByStartDesc(); -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/EventDeleteService.java b/src/main/java/com/weeth/domain/schedule/domain/service/EventDeleteService.java deleted file mode 100644 index 1bcef851..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/EventDeleteService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.schedule.domain.repository.EventRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class EventDeleteService { - - private final EventRepository eventRepository; - - public void delete(Event event) { - eventRepository.delete(event); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/EventGetService.java b/src/main/java/com/weeth/domain/schedule/domain/service/EventGetService.java deleted file mode 100644 index 71546635..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/EventGetService.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.application.mapper.ScheduleMapper; -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.schedule.domain.repository.EventRepository; -import com.weeth.domain.schedule.application.exception.EventNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class EventGetService { - - private final EventRepository eventRepository; - private final ScheduleMapper mapper; - - public Event find(Long eventId) { - return eventRepository.findById(eventId) - .orElseThrow(EventNotFoundException::new); - } - - public List find(LocalDateTime start, LocalDateTime end) { - return eventRepository.findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start).stream() - .map(event -> mapper.toScheduleDTO(event, false)) - .toList(); - } - - public List find(Integer cardinal) { - return eventRepository.findAllByCardinal(cardinal).stream() - .map(event -> mapper.toScheduleDTO(event, false)) - .toList(); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/EventSaveService.java b/src/main/java/com/weeth/domain/schedule/domain/service/EventSaveService.java deleted file mode 100644 index b2c82831..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/EventSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.schedule.domain.repository.EventRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class EventSaveService { - - private final EventRepository eventRepository; - - public void save(Event event) { - eventRepository.save(event); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/EventUpdateService.java b/src/main/java/com/weeth/domain/schedule/domain/service/EventUpdateService.java deleted file mode 100644 index 905af593..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/EventUpdateService.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.user.domain.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@Transactional -@RequiredArgsConstructor -public class EventUpdateService { - - public void update(Event event, ScheduleDTO.Update dto, User user) { - event.update(dto, user); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingDeleteService.java b/src/main/java/com/weeth/domain/schedule/domain/service/MeetingDeleteService.java deleted file mode 100644 index 39fecb02..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingDeleteService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.repository.MeetingRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class MeetingDeleteService { - - private final MeetingRepository meetingRepository; - - public void delete(Meeting meeting) { - meetingRepository.delete(meeting); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingGetService.java b/src/main/java/com/weeth/domain/schedule/domain/service/MeetingGetService.java deleted file mode 100644 index 3eab97d2..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingGetService.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.application.mapper.ScheduleMapper; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.entity.enums.MeetingStatus; -import com.weeth.domain.schedule.domain.repository.MeetingRepository; -import com.weeth.domain.schedule.application.exception.MeetingNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class MeetingGetService { - - private final MeetingRepository meetingRepository; - private final ScheduleMapper mapper; - - public Meeting find(Long meetingId) { - return meetingRepository.findById(meetingId) - .orElseThrow(MeetingNotFoundException::new); - } - - public List find(LocalDateTime start, LocalDateTime end) { - return meetingRepository.findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start).stream() - .map(meeting -> mapper.toScheduleDTO(meeting, true)) - .toList(); - } - - public List find(Integer cardinal) { - return meetingRepository.findAllByCardinalOrderByStartAsc(cardinal); - } - - public List findMeetingByCardinal(Integer cardinal) { - return meetingRepository.findAllByCardinalOrderByStartDesc(cardinal); - } - - public List findAll() { - return meetingRepository.findAllByOrderByStartDesc(); - } - - public List findByCardinal(Integer cardinal) { - return meetingRepository.findAllByCardinal(cardinal).stream() - .map(meeting -> mapper.toScheduleDTO(meeting, true)) - .toList(); - } - - public List findAllOpenMeetingsBeforeNow() { - return meetingRepository.findAllByMeetingStatusAndEndBeforeOrderByEndAsc(MeetingStatus.OPEN, LocalDateTime.now()); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingSaveService.java b/src/main/java/com/weeth/domain/schedule/domain/service/MeetingSaveService.java deleted file mode 100644 index ba671f62..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.repository.MeetingRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class MeetingSaveService { - - private final MeetingRepository meetingRepository; - - public void save(Meeting meeting) { - meetingRepository.save(meeting); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingUpdateService.java b/src/main/java/com/weeth/domain/schedule/domain/service/MeetingUpdateService.java deleted file mode 100644 index e89301c7..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingUpdateService.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.User; -import org.springframework.stereotype.Service; - -@Service -public class MeetingUpdateService { - - public void update(ScheduleDTO.Update dto, User user, Meeting meeting) { - meeting.update(dto, user); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/EventAdminController.java b/src/main/java/com/weeth/domain/schedule/presentation/EventAdminController.java deleted file mode 100644 index 2a8f2a99..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/EventAdminController.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.application.exception.EventErrorCode; -import com.weeth.domain.schedule.application.usecase.EventUseCase; -import com.weeth.domain.schedule.application.usecase.MeetingUseCase; -import com.weeth.domain.schedule.domain.entity.enums.Type; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.*; - -@Tag(name = "EVENT ADMIN", description = "[ADMIN] 일정 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/events") -@ApiErrorCodeExample(EventErrorCode.class) -public class EventAdminController { - - private final EventUseCase eventUseCase; - private final MeetingUseCase meetingUseCase; - - @PostMapping - @Operation(summary = "일정/정기모임 생성") - public CommonResponse save(@Valid @RequestBody ScheduleDTO.Save dto, - @Parameter(hidden = true) @CurrentUser Long userId) { - if (dto.type() == Type.EVENT) { - eventUseCase.save(dto, userId); - } else { - meetingUseCase.save(dto, userId); - } - - return CommonResponse.success(EVENT_SAVE_SUCCESS); - } - - @PatchMapping("/{eventId}") - @Operation(summary = "일정 수정 (type은 변경할 수 없게 해주세요.)") - public CommonResponse update(@PathVariable Long eventId, @Valid @RequestBody ScheduleDTO.Update dto, - @Parameter(hidden = true) @CurrentUser Long userId) { - if (dto.type() == Type.EVENT) { - eventUseCase.update(eventId, dto, userId); - } else { - meetingUseCase.update(dto, userId, eventId); - } - - return CommonResponse.success(EVENT_UPDATE_SUCCESS); - } - - @DeleteMapping("/{eventId}") - @Operation(summary = "일정 삭제") - public CommonResponse delete(@PathVariable Long eventId) { - eventUseCase.delete(eventId); - - return CommonResponse.success(EVENT_DELETE_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/EventController.java b/src/main/java/com/weeth/domain/schedule/presentation/EventController.java deleted file mode 100644 index d94165e9..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/EventController.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.schedule.application.exception.EventErrorCode; -import com.weeth.domain.schedule.application.usecase.EventUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.schedule.application.dto.EventDTO.Response; -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.EVENT_FIND_SUCCESS; - -@Tag(name = "EVENT", description = "일정 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/events") -@ApiErrorCodeExample(EventErrorCode.class) -public class EventController { - - private final EventUseCase eventUseCase; - - @GetMapping("/{eventId}") - @Operation(summary="일정 상세 조회") - public CommonResponse find(@PathVariable Long eventId) { - return CommonResponse.success(EVENT_FIND_SUCCESS, - eventUseCase.find(eventId)); - } - -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/MeetingAdminController.java b/src/main/java/com/weeth/domain/schedule/presentation/MeetingAdminController.java deleted file mode 100644 index dcbc3e19..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/MeetingAdminController.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.schedule.application.exception.MeetingErrorCode; -import com.weeth.domain.schedule.application.usecase.MeetingUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.MEETING_DELETE_SUCCESS; - -@Tag(name = "MEETING ADMIN", description = "[ADMIN] 정기모임 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/meetings") -@ApiErrorCodeExample(MeetingErrorCode.class) -public class MeetingAdminController { - - private final MeetingUseCase meetingUseCase; - - @DeleteMapping("/{meetingId}") - @Operation(summary = "정기모임 삭제") - public CommonResponse delete(@PathVariable Long meetingId) { - meetingUseCase.delete(meetingId); - return CommonResponse.success(MEETING_DELETE_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/MeetingController.java b/src/main/java/com/weeth/domain/schedule/presentation/MeetingController.java deleted file mode 100644 index 3af43758..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/MeetingController.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.schedule.application.dto.MeetingDTO; -import com.weeth.domain.schedule.application.exception.MeetingErrorCode; -import com.weeth.domain.schedule.application.usecase.MeetingUseCase; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.MEETING_FIND_SUCCESS; - -@Tag(name = "MEETING", description = "정기모임 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/meetings") -@ApiErrorCodeExample(MeetingErrorCode.class) -public class MeetingController { - - private final MeetingUseCase meetingUseCase; - - @GetMapping("/{meetingId}") - @Operation(summary="정기모임 상세 조회") - public CommonResponse find(@Parameter(hidden = true) @CurrentUser Long userId, - @PathVariable Long meetingId) { - return CommonResponse.success(MEETING_FIND_SUCCESS, meetingUseCase.find(userId, meetingId)); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/ScheduleController.java b/src/main/java/com/weeth/domain/schedule/presentation/ScheduleController.java deleted file mode 100644 index 7e3203c5..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/ScheduleController.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.schedule.application.exception.EventErrorCode; -import com.weeth.domain.schedule.application.exception.MeetingErrorCode; -import com.weeth.domain.schedule.application.usecase.ScheduleUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -import static com.weeth.domain.schedule.application.dto.ScheduleDTO.Response; -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.SCHEDULE_MONTHLY_FIND_SUCCESS; -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.SCHEDULE_YEARLY_FIND_SUCCESS; - -@Tag(name = "SCHEDULE", description = "캘린더 조회 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/schedules") -@ApiErrorCodeExample({EventErrorCode.class, MeetingErrorCode.class}) -public class ScheduleController { - - private final ScheduleUseCase scheduleUseCase; - - @GetMapping("/monthly") - @Operation(summary="월별 일정 조회") - public CommonResponse> findByMonthly(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end) { - return CommonResponse.success(SCHEDULE_MONTHLY_FIND_SUCCESS,scheduleUseCase.findByMonthly(start, end)); - } - - @GetMapping("/yearly") - @Operation(summary="연도별 일정 조회") - public CommonResponse>> findByYearly(@RequestParam Integer year, - @RequestParam Integer semester) { - return CommonResponse.success(SCHEDULE_YEARLY_FIND_SUCCESS,scheduleUseCase.findByYearly(year, semester)); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/ScheduleResponseCode.java b/src/main/java/com/weeth/domain/schedule/presentation/ScheduleResponseCode.java deleted file mode 100644 index 73655542..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/ScheduleResponseCode.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum ScheduleResponseCode implements ResponseCodeInterface { - // EventAdminController 관련 - EVENT_SAVE_SUCCESS(1700, HttpStatus.OK, "일정/정기모임이 성공적으로 생성되었습니다."), - EVENT_UPDATE_SUCCESS(1701, HttpStatus.OK, "일정/정기모임이 성공적으로 수정되었습니다."), - EVENT_DELETE_SUCCESS(1702, HttpStatus.OK, "일정이 성공적으로 삭제되었습니다."), - // EventController 관련 - EVENT_FIND_SUCCESS(1703, HttpStatus.OK, "일정이 성공적으로 조회되었습니다."), - // MeetingAdminController 관련 - MEETING_SAVE_SUCCESS(1704, HttpStatus.OK, "정기모임 일정이 성공적으로 생성되었습니다."), - MEETING_UPDATE_SUCCESS(1705, HttpStatus.OK, "정기모임 일정이 성공적으로 수정되었습니다."), - MEETING_DELETE_SUCCESS(1706, HttpStatus.OK, "정기모임 일정이 성공적으로 삭제되었습니다."), - MEETING_CARDINAL_FIND_SUCCESS(1707, HttpStatus.OK, "특정 기수 정기모임이 성공적으로 조회되었습니다."), - MEETING_ALL_FIND_SUCCESS(1708, HttpStatus.OK, "정기모임 전체일정이 성공적으로 조회되었습니다."), - // MeetingController 관련 - MEETING_FIND_SUCCESS(1709, HttpStatus.OK, "정기모임이 성공적으로 조회되었습니다."), - // ScheduleController 관련 - SCHEDULE_MONTHLY_FIND_SUCCESS(1710, HttpStatus.OK, "월별 일정이 성공적으로 조회되었습니다."), - SCHEDULE_YEARLY_FIND_SUCCESS(1711, HttpStatus.OK, "연도별 일정이 성공적으로 조회되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - ScheduleResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.java b/src/main/java/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.java deleted file mode 100644 index 3896b1aa..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.domain.user.application.dto.request; - -import jakarta.validation.constraints.NotNull; - -public record CardinalSaveRequest ( - @NotNull Integer cardinalNumber, - @NotNull Integer year, - @NotNull Integer semester, - boolean inProgress -){ -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.java b/src/main/java/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.java deleted file mode 100644 index 029d7154..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.domain.user.application.dto.request; - -import jakarta.validation.constraints.NotNull; - -public record CardinalUpdateRequest( - @NotNull Long id, - @NotNull Integer year, - @NotNull Integer semester, - boolean inProgress -) { -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/request/UserRequestDto.java b/src/main/java/com/weeth/domain/user/application/dto/request/UserRequestDto.java deleted file mode 100644 index 30e16e42..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/request/UserRequestDto.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.weeth.domain.user.application.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.user.domain.entity.enums.Role; - -import java.util.List; - -public class UserRequestDto { - - public record Login( - @NotBlank String authCode - ) { - } - - public record SignUp( - @NotBlank String name, - @Email @NotBlank String email, - @NotBlank String password, - @NotBlank String studentId, - @NotBlank String tel, - @NotNull String position, - @NotNull String department, - @NotNull Integer cardinal - ) { - } - - public record Register( - @Schema(description = "kakao로 회원가입 하는 경우") - Long kakaoId, - @Schema(description = "애플로 회원가입 하는 경우 - Apple OAuth authCode") - String appleAuthCode, - @NotBlank String name, - @NotBlank String studentId, - @NotBlank String email, - @NotNull String department, - @NotBlank String tel, - @NotNull Integer cardinal, - @NotNull String position - ) { - } - - public record Update( - @NotBlank String name, - @Email @NotBlank String email, - @NotBlank String studentId, - @NotBlank String tel, - @NotNull String department - ) { - } - - public record NormalLogin( - @Email @NotBlank String email, - @NotBlank String passWord, - @NotNull Long kakaoId - ) { - } - - public record UserRoleUpdate( - @NotNull Long userId, - @NotNull Role role - ) { - } - - public record UserApplyOB( - @NotNull Long userId, - @NotNull Integer cardinal - ) { - } - - public record UserId( - @NotNull List userId - ) { - } - -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/response/CardinalResponse.java b/src/main/java/com/weeth/domain/user/application/dto/response/CardinalResponse.java deleted file mode 100644 index c296be9f..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/response/CardinalResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.weeth.domain.user.application.dto.response; - -import com.weeth.domain.user.domain.entity.enums.CardinalStatus; - -import java.time.LocalDateTime; - -public record CardinalResponse( - Long id, - Integer cardinalNumber, - Integer year, - Integer semester, - CardinalStatus status, - LocalDateTime createdAt, - LocalDateTime modifiedAt -) { -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/response/UserCardinalDto.java b/src/main/java/com/weeth/domain/user/application/dto/response/UserCardinalDto.java deleted file mode 100644 index a7f2f6fb..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/response/UserCardinalDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.domain.user.application.dto.response; - -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; - -import java.util.List; - -public record UserCardinalDto( - User user, - List cardinals -) { -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java b/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java deleted file mode 100644 index 50112da1..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.weeth.domain.user.application.dto.response; - -import com.weeth.domain.user.domain.entity.enums.LoginStatus; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.entity.enums.Status; - -import java.time.LocalDateTime; -import java.util.List; - -public class UserResponseDto { - - public record SocialLoginResponse( - Long id, - Long kakaoId, - String appleIdToken, - LoginStatus status, - String accessToken, - String refreshToken - ) { - } - - public record Response( - Integer id, - String name, - String email, - String studentId, - String tel, - String department, - List cardinals, - Position position, - Role role - ) { - } - - public record SummaryResponse( - Integer id, - String name, - List cardinals, - Position position, - Role role - ) { - } - - public record AdminResponse( - Integer id, - String name, - String email, - String studentId, - String tel, - String department, - List cardinals, - Position position, - Status status, - Role role, - Integer attendanceCount, - Integer absenceCount, - Integer attendanceRate, - Integer penaltyCount, - Integer warningCount, - LocalDateTime createdAt, - LocalDateTime modifiedAt - ) { - } - - public record UserResponse( - Integer id, - String name, - String email, - String studentId, - String department, - List cardinals, - Position position, - Role role - ) { - } - - public record SocialAuthResponse( - Long kakaoId - ) { - } - - public record UserInfo( - Long id, - String name, - List cardinals, - Role role - ) { - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/CardinalNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/CardinalNotFoundException.java deleted file mode 100644 index fb4568e9..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/CardinalNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class CardinalNotFoundException extends BaseException { - public CardinalNotFoundException() { - super(UserErrorCode.CARDINAL_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/DepartmentNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/DepartmentNotFoundException.java deleted file mode 100644 index bf8abbbb..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/DepartmentNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class DepartmentNotFoundException extends BaseException { - public DepartmentNotFoundException() { - super(UserErrorCode.DEPARTMENT_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/DuplicateCardinalException.java b/src/main/java/com/weeth/domain/user/application/exception/DuplicateCardinalException.java deleted file mode 100644 index 02646132..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/DuplicateCardinalException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class DuplicateCardinalException extends BaseException { - public DuplicateCardinalException() { - super(UserErrorCode.DUPLICATE_CARDINAL); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/EmailNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/EmailNotFoundException.java deleted file mode 100644 index 69b9fda5..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/EmailNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class EmailNotFoundException extends BaseException { - public EmailNotFoundException() { - super(UserErrorCode.EMAIL_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/InvalidUserOrderException.java b/src/main/java/com/weeth/domain/user/application/exception/InvalidUserOrderException.java deleted file mode 100644 index 179dc19f..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/InvalidUserOrderException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class InvalidUserOrderException extends BaseException { - public InvalidUserOrderException() { - super(UserErrorCode.INVALID_USER_ORDER); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/PasswordMismatchException.java b/src/main/java/com/weeth/domain/user/application/exception/PasswordMismatchException.java deleted file mode 100644 index cb5af50e..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/PasswordMismatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class PasswordMismatchException extends BaseException { - public PasswordMismatchException() { - super(UserErrorCode.PASSWORD_MISMATCH); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/RoleNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/RoleNotFoundException.java deleted file mode 100644 index 9bfe4c15..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/RoleNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class RoleNotFoundException extends BaseException { - public RoleNotFoundException() { - super(UserErrorCode.ROLE_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/StatusNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/StatusNotFoundException.java deleted file mode 100644 index f09d7fd1..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/StatusNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class StatusNotFoundException extends BaseException { - public StatusNotFoundException() { - super(UserErrorCode.STATUS_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/StudentIdExistsException.java b/src/main/java/com/weeth/domain/user/application/exception/StudentIdExistsException.java deleted file mode 100644 index 4c9e3271..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/StudentIdExistsException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class StudentIdExistsException extends BaseException { - public StudentIdExistsException() { - super(UserErrorCode.STUDENT_ID_EXISTS); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/TelExistsException.java b/src/main/java/com/weeth/domain/user/application/exception/TelExistsException.java deleted file mode 100644 index 53e613e6..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/TelExistsException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class TelExistsException extends BaseException { - public TelExistsException() { - super(UserErrorCode.TEL_EXISTS); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.java deleted file mode 100644 index cf785d5e..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserCardinalNotFoundException extends BaseException { - public UserCardinalNotFoundException() { - super(UserErrorCode.USER_CARDINAL_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserErrorCode.java b/src/main/java/com/weeth/domain/user/application/exception/UserErrorCode.java deleted file mode 100644 index 9fd45632..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserErrorCode.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum UserErrorCode implements ErrorCodeInterface { - // User 관련 에러 - @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") - USER_NOT_FOUND(2800, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), - - @ExplainError("가입 승인 대기 중인 사용자가 접근을 시도할 때 발생합니다.") - USER_INACTIVE(2801, HttpStatus.FORBIDDEN, "가입 승인이 허가되지 않은 계정입니다."), - - @ExplainError("이미 가입된 이메일로 회원가입을 시도할 때 발생합니다.") - USER_EXISTS(2802, HttpStatus.BAD_REQUEST, "이미 가입된 사용자입니다."), - - @ExplainError("요청한 사용자 정보와 실제 사용자 정보가 일치하지 않을 때 발생합니다.") - USER_MISMATCH(2803, HttpStatus.FORBIDDEN, "사용자 정보가 일치하지 않습니다."), - - @ExplainError("다른 사용자의 리소스에 접근하려고 할 때 발생합니다.") - USER_NOT_MATCH(2804, HttpStatus.FORBIDDEN, "해당 사용자가 아닙니다."), - - // 인증 관련 에러 - @ExplainError("로그인 시 비밀번호가 일치하지 않을 때 발생합니다.") - PASSWORD_MISMATCH(2805, HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), - - @ExplainError("입력한 이메일로 등록된 사용자가 없을 때 발생합니다.") - EMAIL_NOT_FOUND(2806, HttpStatus.NOT_FOUND, "이메일을 찾을 수 없습니다."), - - // 검증 에러 - @ExplainError("이미 등록된 학번으로 회원가입을 시도할 때 발생합니다.") - STUDENT_ID_EXISTS(2807, HttpStatus.BAD_REQUEST, "이미 존재하는 학번입니다."), - - @ExplainError("이미 등록된 전화번호로 회원가입을 시도할 때 발생합니다.") - TEL_EXISTS(2808, HttpStatus.BAD_REQUEST, "이미 존재하는 전화번호입니다."), - - // Cardinal 관련 에러 - @ExplainError("존재하지 않는 기수 정보로 조회할 때 발생합니다.") - CARDINAL_NOT_FOUND(2809, HttpStatus.NOT_FOUND, "기수를 찾을 수 없습니다."), - - @ExplainError("이미 존재하는 기수를 생성하려고 할 때 발생합니다.") - DUPLICATE_CARDINAL(2810, HttpStatus.BAD_REQUEST, "이미 존재하는 기수입니다."), - - @ExplainError("사용자와 기수 간의 연결 정보를 찾을 수 없을 때 발생합니다.") - USER_CARDINAL_NOT_FOUND(2811, HttpStatus.NOT_FOUND, "사용자 기수 정보를 찾을 수 없습니다."), - - // Enum 관련 에러 - @ExplainError("잘못된 학과 값이 입력되었을 때 발생합니다.") - DEPARTMENT_NOT_FOUND(2812, HttpStatus.BAD_REQUEST, "학과를 찾을 수 없습니다."), - - @ExplainError("잘못된 권한 값이 입력되었을 때 발생합니다.") - ROLE_NOT_FOUND(2813, HttpStatus.BAD_REQUEST, "권한을 찾을 수 없습니다."), - - @ExplainError("잘못된 상태 값이 입력되었을 때 발생합니다.") - STATUS_NOT_FOUND(2814, HttpStatus.BAD_REQUEST, "상태를 찾을 수 없습니다."), - - @ExplainError("사용자 순서 지정 시 잘못된 값이 입력되었을 때 발생합니다.") - INVALID_USER_ORDER(2815, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserExistsException.java b/src/main/java/com/weeth/domain/user/application/exception/UserExistsException.java deleted file mode 100644 index d14b6f37..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserExistsException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserExistsException extends BaseException { - public UserExistsException() { - super(UserErrorCode.USER_EXISTS); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserInActiveException.java b/src/main/java/com/weeth/domain/user/application/exception/UserInActiveException.java deleted file mode 100644 index 2090edb5..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserInActiveException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserInActiveException extends BaseException { - public UserInActiveException() { - super(UserErrorCode.USER_INACTIVE); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserMismatchException.java b/src/main/java/com/weeth/domain/user/application/exception/UserMismatchException.java deleted file mode 100644 index e63db955..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserMismatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserMismatchException extends BaseException { - public UserMismatchException() { - super(UserErrorCode.USER_MISMATCH); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/UserNotFoundException.java deleted file mode 100644 index a4fbd495..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserNotFoundException extends BaseException { - public UserNotFoundException() { - super(UserErrorCode.USER_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserNotMatchException.java b/src/main/java/com/weeth/domain/user/application/exception/UserNotMatchException.java deleted file mode 100644 index b7ff871f..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserNotMatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserNotMatchException extends BaseException { - public UserNotMatchException() { - super(UserErrorCode.USER_NOT_MATCH); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/mapper/CardinalMapper.java b/src/main/java/com/weeth/domain/user/application/mapper/CardinalMapper.java deleted file mode 100644 index ca39e9da..00000000 --- a/src/main/java/com/weeth/domain/user/application/mapper/CardinalMapper.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.weeth.domain.user.application.mapper; - -import com.weeth.domain.user.application.dto.request.CardinalSaveRequest; -import com.weeth.domain.user.application.dto.response.CardinalResponse; -import com.weeth.domain.user.application.dto.response.UserCardinalDto; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import org.mapstruct.Mapper; -import org.mapstruct.MappingConstants; -import org.mapstruct.ReportingPolicy; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface CardinalMapper { - - Cardinal from(CardinalSaveRequest dto); - - CardinalResponse to(Cardinal cardinal); - - UserCardinalDto toUserCardinalDto(User user, List cardinals); -} diff --git a/src/main/java/com/weeth/domain/user/application/mapper/UserMapper.java b/src/main/java/com/weeth/domain/user/application/mapper/UserMapper.java deleted file mode 100644 index a0f36096..00000000 --- a/src/main/java/com/weeth/domain/user/application/mapper/UserMapper.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.weeth.domain.user.application.mapper; - -import com.weeth.domain.user.application.dto.response.UserResponseDto; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.entity.enums.Department; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import org.mapstruct.*; -import org.springframework.security.crypto.password.PasswordEncoder; - -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.Register; -import static com.weeth.domain.user.application.dto.request.UserRequestDto.SignUp; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.*; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface UserMapper { - - @Mappings({ - @Mapping(target = "password", expression = "java( passwordEncoder.encode(dto.password()) )"), - @Mapping(target = "department", expression = "java( com.weeth.domain.user.domain.entity.enums.Department.to(dto.department()) )") - }) - User from(SignUp dto, @Context PasswordEncoder passwordEncoder); - - @Mappings({ - @Mapping(target = "department", expression = "java( com.weeth.domain.user.domain.entity.enums.Department.to(dto.department()) )") - }) - User from(Register dto); - - @Mapping(target = "department", expression = "java( toString(user.getDepartment()) )") - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - Response to(User user, List userCardinals); - - @Mappings({ - // 수정: 출석률, 출석 횟수, 결석 횟수 매핑 추후 추가 예정 - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - }) - AdminResponse toAdminResponse(User user, List userCardinals); - - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - SummaryResponse toSummaryResponse(User user, List userCardinals); - - SocialAuthResponse toSocialAuthResponse(Long kakaoId); - - @Mappings({ - @Mapping(target = "status", expression = "java(LoginStatus.LOGIN)"), - @Mapping(target = "id", source = "user.id"), - @Mapping(target = "kakaoId", source = "user.kakaoId"), - @Mapping(target = "appleIdToken", expression = "java(null)") - }) - SocialLoginResponse toLoginResponse(User user, JwtDto dto); - - @Mappings({ - @Mapping(target = "status", expression = "java(LoginStatus.INTEGRATE)"), - @Mapping(target = "appleIdToken", expression = "java(null)"), - @Mapping(target = "accessToken", expression = "java(null)"), - @Mapping(target = "refreshToken", expression = "java(null)") - }) - SocialLoginResponse toIntegrateResponse(Long kakaoId); - - @Mappings({ - // 상세 데이터 매핑 - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - }) - UserResponse toUserResponse(User user, List userCardinals); - - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - UserResponseDto.UserInfo toUserInfoDto(User user, List userCardinals); - - @Mappings({ - @Mapping(target = "status", expression = "java(LoginStatus.LOGIN)"), - @Mapping(target = "id", source = "user.id"), - @Mapping(target = "appleIdToken", expression = "java(null)"), - @Mapping(target = "kakaoId", expression = "java(null)") - }) - SocialLoginResponse toAppleLoginResponse(User user, JwtDto dto); - - @Mappings({ - @Mapping(target = "status", expression = "java(LoginStatus.INTEGRATE)"), - @Mapping(target = "id", expression = "java(null)"), - @Mapping(target = "appleIdToken", source = "appleIdToken"), - @Mapping(target = "kakaoId", expression = "java(null)"), - @Mapping(target = "accessToken", expression = "java(null)"), - @Mapping(target = "refreshToken", expression = "java(null)") - }) - SocialLoginResponse toAppleIntegrateResponse(String appleIdToken); - - default String toString(Department department) { - return department.getValue(); - } - - default List toCardinalNumbers(List userCardinals) { - if (userCardinals == null || userCardinals.isEmpty()) { - return Collections.emptyList(); - } - - return userCardinals.stream() - .map(uc -> uc.getCardinal().getCardinalNumber()) - .collect(Collectors.toList()); - } -} - diff --git a/src/main/java/com/weeth/domain/user/application/usecase/CardinalUseCase.java b/src/main/java/com/weeth/domain/user/application/usecase/CardinalUseCase.java deleted file mode 100644 index 1723054e..00000000 --- a/src/main/java/com/weeth/domain/user/application/usecase/CardinalUseCase.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.weeth.domain.user.application.usecase; - -import com.weeth.domain.user.application.dto.request.CardinalSaveRequest; -import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest; -import com.weeth.domain.user.application.dto.response.CardinalResponse; -import com.weeth.domain.user.application.mapper.CardinalMapper; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.service.CardinalGetService; -import com.weeth.domain.user.domain.service.CardinalSaveService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class CardinalUseCase { - - private final CardinalGetService cardinalGetService; - private final CardinalSaveService cardinalSaveService; - - private final CardinalMapper cardinalMapper; - - @Transactional - public void save(CardinalSaveRequest dto) { - cardinalGetService.validateCardinal(dto.cardinalNumber()); - - Cardinal cardinal = cardinalSaveService.save(cardinalMapper.from(dto)); - - if (dto.inProgress()) { - updateCardinalStatus(cardinal); - } - } - - @Transactional - public void update(CardinalUpdateRequest dto) { - Cardinal cardinal = cardinalGetService.findById(dto.id()); - - cardinal.update(dto); - - if (dto.inProgress()) { - updateCardinalStatus(cardinal); - } - } - - public List findAll() { - List cardinals = cardinalGetService.findAll(); - return cardinals.stream() - .map(cardinalMapper::to) - .toList(); - } - - private void updateCardinalStatus(Cardinal cardinal) { - List cardinals = cardinalGetService.findInProgress(); - - if (!cardinals.isEmpty()) { - cardinals.forEach(Cardinal::done); - } - - cardinal.inProgress(); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCase.java b/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCase.java deleted file mode 100644 index 7e3bc11f..00000000 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCase.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.weeth.domain.user.application.usecase; - -import com.weeth.domain.user.application.dto.response.UserResponseDto; -import com.weeth.domain.user.domain.entity.enums.UsersOrderBy; - -import java.util.List; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; - -public interface UserManageUseCase { - - - List findAllByAdmin(UsersOrderBy orderBy); - - void accept(UserId userIds); - - void update(List request); - - void leave(Long userId); - - void ban(UserId userIds); - - void applyOB(List request); - - void reset(UserId userId); -} diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java b/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java deleted file mode 100644 index b9e1b630..00000000 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.weeth.domain.user.application.usecase; - -import jakarta.transaction.Transactional; -import com.weeth.domain.attendance.domain.service.AttendanceSaveService; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.service.MeetingGetService; -import com.weeth.domain.user.application.exception.InvalidUserOrderException; -import com.weeth.domain.user.application.mapper.UserMapper; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.entity.enums.StatusPriority; -import com.weeth.domain.user.domain.entity.enums.UsersOrderBy; -import com.weeth.domain.user.domain.service.*; -import com.weeth.global.auth.jwt.service.JwtRedisService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -import java.util.*; -import java.util.stream.Collectors; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.AdminResponse; -import static com.weeth.domain.user.domain.entity.enums.UsersOrderBy.CARDINAL_DESCENDING; -import static com.weeth.domain.user.domain.entity.enums.UsersOrderBy.NAME_ASCENDING; - -@Service -@RequiredArgsConstructor -public class UserManageUseCaseImpl implements UserManageUseCase { - - private final UserGetService userGetService; - private final UserUpdateService userUpdateService; - private final UserDeleteService userDeleteService; - - private final AttendanceSaveService attendanceSaveService; - private final MeetingGetService meetingGetService; - private final JwtRedisService jwtRedisService; - private final CardinalGetService cardinalGetService; - private final UserCardinalSaveService userCardinalSaveService; - private final UserCardinalGetService userCardinalGetService; - - private final UserMapper mapper; - private final PasswordEncoder passwordEncoder; - - @Override - public List findAllByAdmin(UsersOrderBy orderBy) { - if (orderBy == null || !EnumSet.allOf(UsersOrderBy.class).contains(orderBy)) { - throw new InvalidUserOrderException(); - } - - Map> userCardinalMap = userCardinalGetService.findAll() - .stream() - .collect(Collectors.groupingBy(UserCardinal::getUser, LinkedHashMap::new, Collectors.toList())); - - if (orderBy.equals(NAME_ASCENDING)) { - return userCardinalMap.entrySet() - .stream() - .sorted(Comparator - .comparingInt(((Map.Entry> entry) -> (StatusPriority.fromStatus(entry.getKey().getStatus())).getPriority()))) - .map(entry -> { - List userCardinals = userCardinalGetService.getUserCardinals(entry.getKey()); - return mapper.toAdminResponse(entry.getKey(), userCardinals); - }) - .toList(); - } - - if (orderBy.equals(CARDINAL_DESCENDING)) { - - return userCardinalMap.entrySet() - .stream() - .sorted(Comparator - .comparingInt(((Map.Entry> entry) -> (StatusPriority.fromStatus(entry.getKey().getStatus())).getPriority())) - .thenComparing(entry -> entry.getValue().stream() - .map(uc -> uc.getCardinal().getCardinalNumber()) - .max(Integer::compare) - .orElse(-1), Comparator.reverseOrder())) - .map(entry -> { - List userCardinals = userCardinalGetService.getUserCardinals(entry.getKey()); - return mapper.toAdminResponse(entry.getKey(), userCardinals); - }) - .toList(); - } - - return null; - } - - @Override - @Transactional - public void accept(UserId userIds) { - List users = userGetService.findAll(userIds.userId()); - - users.forEach(user -> { - Integer cardinal = userCardinalGetService.getCurrentCardinal(user).getCardinalNumber(); - - if (user.isInactive()) { - userUpdateService.accept(user); - List meetings = meetingGetService.find(cardinal); - attendanceSaveService.init(user, meetings); - } - }); - } - - @Override - @Transactional - public void update(List requests) { - requests.forEach(request -> { - User user = userGetService.find(request.userId()); - - userUpdateService.update(user, request.role().name()); - jwtRedisService.updateRole(user.getId(), request.role().name()); - }); - } - - @Override - public void leave(Long userId) { - User user = userGetService.find(userId); - // 탈퇴하는 경우 리프레시 토큰 삭제 - jwtRedisService.delete(user.getId()); - userDeleteService.leave(user); - } - - @Override - public void ban(UserId userIds) { - List users = userGetService.findAll(userIds.userId()); - - users.forEach(user -> { - jwtRedisService.delete(user.getId()); - userDeleteService.ban(user); - }); - } - - @Override - @Transactional - public void applyOB(List requests) { - requests.forEach(request -> { - User user = userGetService.find(request.userId()); - Cardinal nextCardinal = cardinalGetService.findByAdminSide(request.cardinal()); - - if (userCardinalGetService.notContains(user, nextCardinal)) { - if (userCardinalGetService.isCurrent(user, nextCardinal)) { - user.initAttendance(); - List meetings = meetingGetService.find(request.cardinal()); - attendanceSaveService.init(user, meetings); - } - UserCardinal userCardinal = new UserCardinal(user, nextCardinal); - - userCardinalSaveService.save(userCardinal); - } - }); - } - - @Override - @Transactional - public void reset(UserId userId) { - - List users = userGetService.findAll(userId.userId()); - - users.forEach(user -> userUpdateService.reset(user, passwordEncoder)); - } - -} diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCase.java b/src/main/java/com/weeth/domain/user/application/usecase/UserUseCase.java deleted file mode 100644 index f549ea9d..00000000 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCase.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.weeth.domain.user.application.usecase; - -import com.weeth.domain.user.application.dto.request.UserRequestDto; -import com.weeth.domain.user.application.dto.response.UserResponseDto; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import org.springframework.data.domain.Slice; - -import java.util.List; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.*; - - -public interface UserUseCase { - - SocialLoginResponse login(Login dto); - - SocialAuthResponse authenticate(Login dto); - - SocialLoginResponse integrate(NormalLogin dto); - - UserResponseDto.Response find(Long userId); - - Slice findAllUser(int pageNumber, int pageSize, Integer cardinal); - - UserResponseDto.UserResponse findUserDetails(Long userId); - - void update(UserRequestDto.Update dto, Long userId); - - void apply(SignUp dto); - - void socialRegister(Register dto); - - JwtDto refresh(String refreshToken); - - UserResponseDto.UserInfo findUserInfo(Long userId); - - List searchUser(String keyword); - - SocialLoginResponse appleLogin(Login dto); - - void appleRegister(Register dto); - -} diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java b/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java deleted file mode 100644 index 9f9e14e5..00000000 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java +++ /dev/null @@ -1,314 +0,0 @@ -package com.weeth.domain.user.application.usecase; - -import com.weeth.domain.user.application.dto.response.UserCardinalDto; -import com.weeth.domain.user.application.exception.PasswordMismatchException; -import com.weeth.domain.user.application.exception.StudentIdExistsException; -import com.weeth.domain.user.application.exception.TelExistsException; -import com.weeth.domain.user.application.exception.UserInActiveException; -import com.weeth.domain.user.application.mapper.CardinalMapper; -import com.weeth.domain.user.application.mapper.UserMapper; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.service.*; -import com.weeth.global.auth.apple.dto.AppleTokenResponse; -import com.weeth.global.auth.apple.dto.AppleUserInfo; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase; -import com.weeth.global.auth.kakao.KakaoAuthService; -import com.weeth.global.auth.kakao.dto.KakaoTokenResponse; -import com.weeth.global.auth.kakao.dto.KakaoUserInfoResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.env.Environment; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.*; -import java.util.stream.Collectors; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.*; - -@Slf4j -@Service -@RequiredArgsConstructor -public class UserUseCaseImpl implements UserUseCase { - private static final String BEARER = "Bearer "; - private final JwtManageUseCase jwtManageUseCase; - private final UserSaveService userSaveService; - private final UserGetService userGetService; - private final UserUpdateService userUpdateService; - private final KakaoAuthService kakaoAuthService; - private final com.weeth.global.auth.apple.AppleAuthService appleAuthService; - private final CardinalGetService cardinalGetService; - private final UserCardinalSaveService userCardinalSaveService; - private final UserCardinalGetService userCardinalGetService; - - private final UserMapper mapper; - private final CardinalMapper cardinalMapper; - private final PasswordEncoder passwordEncoder; - private final Environment environment; - - @Override - @Transactional(readOnly = true) - public SocialLoginResponse login(Login dto) { - long kakaoId = getKakaoId(dto); - Optional optionalUser = userGetService.findByKakaoId(kakaoId); - - if (optionalUser.isEmpty()) { - return mapper.toIntegrateResponse(kakaoId); - } - - User user = optionalUser.get(); - if (user.isInactive()) { - throw new UserInActiveException(); - } - - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); - return mapper.toLoginResponse(user, token); - } - - @Override - public SocialAuthResponse authenticate(Login dto) { - long kakaoId = getKakaoId(dto); - - return mapper.toSocialAuthResponse(kakaoId); - } - - @Override - @Transactional - public SocialLoginResponse integrate(NormalLogin dto) { - User user = userGetService.find(dto.email()); - - if (!passwordEncoder.matches(dto.passWord(), user.getPassword())) { - throw new PasswordMismatchException(); - } - user.addKakaoId(dto.kakaoId()); - - if (user.isInactive()) { - throw new UserInActiveException(); - } - - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); - - return mapper.toLoginResponse(user, token); - } - - @Override - public Slice findAllUser(int pageNumber, int pageSize, Integer cardinal) { - - Pageable pageable = PageRequest.of(pageNumber, pageSize); - Slice users; - - if (cardinal == null) { - users = userGetService.findAll(pageable); - - } else { - Cardinal inputCardinal = cardinalGetService.findByUserSide(cardinal); - users = userGetService.findAll(pageable, inputCardinal); - } - - List allUserCardinals = userCardinalGetService.findAll(users.getContent()); - - Map> userCardinalMap = allUserCardinals.stream() - .collect(Collectors.groupingBy(userCardinal -> userCardinal.getUser().getId())); - - return users.map(user -> { - List userCardinals = userCardinalMap.getOrDefault(user.getId(), Collections.emptyList()); - - return mapper.toSummaryResponse(user, userCardinals); - }); - } - - @Override - public UserResponse findUserDetails(Long userId) { - UserCardinalDto dto = getUserCardinalDto(userId); - - return mapper.toUserResponse(dto.user(), dto.cardinals()); - } - - @Override - public Response find(Long userId) { - UserCardinalDto dto = getUserCardinalDto(userId); - - return mapper.to(dto.user(), dto.cardinals()); - } - - @Override - public void update(Update dto, Long userId) { - validate(dto, userId); - User user = userGetService.find(userId); - userUpdateService.update(user, dto); - } - - @Override - @Transactional - public void apply(SignUp dto) { - validate(dto); - - Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); - User user = mapper.from(dto, passwordEncoder); - UserCardinal userCardinal = new UserCardinal(user, cardinal); - - userSaveService.save(user); - userCardinalSaveService.save(userCardinal); - } - - @Override - @Transactional - public void socialRegister(Register dto) { - validate(dto); - - Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); - - User user = mapper.from(dto); - UserCardinal userCardinal = new UserCardinal(user, cardinal); - - userSaveService.save(user); - userCardinalSaveService.save(userCardinal); - } - - @Override - @Transactional - public JwtDto refresh(String refreshToken) { - - String requestToken = refreshToken.replace(BEARER, ""); - - JwtDto token = jwtManageUseCase.reIssueToken(requestToken); - - log.info("RefreshToken 발급 완료: {}", token); - return new JwtDto(token.accessToken(), token.refreshToken()); - } - - @Override - public UserInfo findUserInfo(Long userId) { - UserCardinalDto dto = getUserCardinalDto(userId); - - return mapper.toUserInfoDto(dto.user(), dto.cardinals()); - } - - @Override - public List searchUser(String keyword) { - List users = userGetService.search(keyword); - - return users.stream() - .map(user -> { - List userCardinals = userCardinalGetService.getUserCardinals(user); - return mapper.toSummaryResponse(user, userCardinals); - }) - .toList(); - } - - private long getKakaoId(Login dto) { - KakaoTokenResponse tokenResponse = kakaoAuthService.getKakaoToken(dto.authCode()); - KakaoUserInfoResponse userInfo = kakaoAuthService.getUserInfo(tokenResponse.access_token()); - - return userInfo.id(); - } - - private void validate(Update dto, Long userId) { - if (userGetService.validateStudentId(dto.studentId(), userId)) - throw new StudentIdExistsException(); - if (userGetService.validateTel(dto.tel(), userId)) - throw new TelExistsException(); - } - - private void validate(SignUp dto) { - if (userGetService.validateStudentId(dto.studentId())) - throw new StudentIdExistsException(); - if (userGetService.validateTel(dto.tel())) - throw new TelExistsException(); - } - - private void validate(Register dto) { - if (userGetService.validateStudentId(dto.studentId())) { - throw new StudentIdExistsException(); - } - if (userGetService.validateTel(dto.tel())) { - throw new TelExistsException(); - } - } - - private UserCardinalDto getUserCardinalDto(Long userId) { - User user = userGetService.find(userId); - List userCardinals = userCardinalGetService.getUserCardinals(user); - - return cardinalMapper.toUserCardinalDto(user, userCardinals); - } - - @Override - @Transactional(readOnly = true) - public SocialLoginResponse appleLogin(Login dto) { - // Apple Token 요청 및 유저 정보 요청 - AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.authCode()); - AppleUserInfo userInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.id_token()); - - String appleIdToken = tokenResponse.id_token(); - String appleId = userInfo.appleId(); - - Optional optionalUser = userGetService.findByAppleId(appleId); - - //todo: 추후 애플 로그인 연동을 위해 appleIdToken을 반환 - // 애플 로그인 연동 API 요청시 appleIdToken을 함께 넣어주면 그때 디코딩해서 appleId를 추출 - if (optionalUser.isEmpty()) { - return mapper.toAppleIntegrateResponse(appleIdToken); - } - - User user = optionalUser.get(); - if (user.isInactive()) { - throw new UserInActiveException(); - } - - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); - return mapper.toAppleLoginResponse(user, token); - } - - @Override - @Transactional - public void appleRegister(Register dto) { - validate(dto); - - // Apple authCode로 토큰 교환 후 ID Token 검증 및 사용자 정보 추출 - AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.appleAuthCode()); - AppleUserInfo appleUserInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.id_token()); - - Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); - - User user = mapper.from(dto); - // Apple ID 설정 - user.addAppleId(appleUserInfo.appleId()); - - UserCardinal userCardinal = new UserCardinal(user, cardinal); - - userSaveService.save(user); - userCardinalSaveService.save(userCardinal); - - // dev 환경에서만 바로 ACTIVE 상태로 설정 - if (isDevEnvironment()) { - log.info("dev 환경 감지: 사용자 자동 승인 처리 (userId: {})", user.getId()); - user.accept(); - } - } - - /** - * 현재 환경이 dev 프로파일인지 확인 - * @return dev 프로파일이 활성화되어 있으면 true - */ - private boolean isDevEnvironment() { - String[] activeProfiles = environment.getActiveProfiles(); - for (String profile : activeProfiles) { - if ("dev".equals(profile)) { - return true; - } - if ("local".equals(profile)) { - return true; - } - } - return false; - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/Cardinal.java b/src/main/java/com/weeth/domain/user/domain/entity/Cardinal.java deleted file mode 100644 index 942e94e0..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/Cardinal.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.weeth.domain.user.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest; -import com.weeth.domain.user.domain.entity.enums.CardinalStatus; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Cardinal extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "cardinal_id") - private Long id; - - @Column(unique = true, nullable = false) - private Integer cardinalNumber; - - private Integer year; - - private Integer semester; - - @Builder.Default - @Enumerated(EnumType.STRING) - CardinalStatus status = CardinalStatus.DONE; - - public void update(CardinalUpdateRequest dto) { - this.year = dto.year(); - this.semester = dto.semester(); - } - - public void inProgress() { - this.status = CardinalStatus.IN_PROGRESS; - } - - public void done() { - this.status = CardinalStatus.DONE; - } - -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/SecurityUser.java b/src/main/java/com/weeth/domain/user/domain/entity/SecurityUser.java deleted file mode 100644 index 8ed2a2ee..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/SecurityUser.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.weeth.domain.user.domain.entity; - -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import java.io.Serial; -import java.io.Serializable; -import java.util.Collection; -import java.util.List; - -public record SecurityUser( - Long id, - String email, - String name, - String role, - boolean active -) implements UserDetails, Serializable { - - @Serial - private static final long serialVersionUID = 1L; - - public static SecurityUser from(User u) { - return new SecurityUser( - u.getId(), - u.getEmail(), - u.getName(), - u.getRole().name(), - !u.isInactive() - ); - } - - @Override - public Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority("ROLE_" + role)); - } - - @Override - public String getPassword() { - return "N/A"; - } - - @Override - public String getUsername() { - return name; - } - - @Override - public boolean isAccountNonExpired() { - return active; - } - - @Override - public boolean isAccountNonLocked() { - return active; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return active; - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/User.java b/src/main/java/com/weeth/domain/user/domain/entity/User.java deleted file mode 100644 index 17a99a31..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/User.java +++ /dev/null @@ -1,201 +0,0 @@ -package com.weeth.domain.user.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.user.domain.entity.enums.Department; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.entity.enums.Status; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; -import org.springframework.security.crypto.password.PasswordEncoder; - -import java.util.ArrayList; -import java.util.List; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.Update; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "users") -@AllArgsConstructor -@SuperBuilder -public class User extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_id") - private Long id; - - @Column(unique = true) - private Long kakaoId; - - @Column(unique = true) - private String appleId; - - private String name; - - private String email; - - private String password; - - private String studentId; - - private String tel; - - @Enumerated(EnumType.STRING) - private Position position; - - @Enumerated(EnumType.STRING) - private Department department; - - @Enumerated(EnumType.STRING) - private Status status; - - @Enumerated(EnumType.STRING) - private Role role; - - private Integer attendanceCount; - - private Integer absenceCount; - - private Integer attendanceRate; - - private Integer penaltyCount; - - private Integer warningCount; - - @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true) - private List attendances = new ArrayList<>(); - - @PrePersist - public void init() { - status = Status.WAITING; - role = Role.USER; - attendanceCount = 0; - absenceCount = 0; - attendanceRate = 0; - penaltyCount = 0; - warningCount = 0; - } - - public void addKakaoId(long kakaoId) { - this.kakaoId = kakaoId; - } - - public void addAppleId(String appleId) { - this.appleId = appleId; - } - - public void leave() { - this.status = Status.LEFT; - } - - /* - todo 차후 일반 로그인 비활성화시 해당 메서드에서 예외를 날리도록 수정 - */ - public boolean isInactive() { - return this.status != Status.ACTIVE; - } - - public void update(Update dto) { - this.name = dto.name(); - this.email = dto.email(); - this.studentId = dto.studentId(); - this.tel = dto.tel(); - this.department = Department.to(dto.department()); - } - - public void accept() { - this.status = Status.ACTIVE; - } - - public void ban() { - this.status = Status.BANNED; - } - - public void update(String role) { - this.role = Role.valueOf(role); - } - - public void reset(PasswordEncoder passwordEncoder) { - this.password = passwordEncoder.encode(studentId); - } - - public void add(Attendance attendance) { - this.attendances.add(attendance); - } - - public void initAttendance() { - this.attendances.clear(); - this.attendanceCount = 0; - this.absenceCount = 0; - this.attendanceRate = 0; - } - - public void attend() { - attendanceCount++; - calculateRate(); - } - - public void removeAttend() { - if (attendanceCount > 0) { - attendanceCount--; - calculateRate(); - } - } - - public void absent() { - absenceCount++; - calculateRate(); - } - - public void removeAbsent() { - if (absenceCount > 0) { - absenceCount--; - calculateRate(); - } - } - - private void calculateRate() { - if (attendanceCount + absenceCount > 0) { - attendanceRate = (attendanceCount * 100) / (attendanceCount + absenceCount); - } else { - attendanceRate = 0; - } - } - - public void incrementPenaltyCount() { - penaltyCount++; - } - - public void decrementPenaltyCount() { - if (penaltyCount > 0) { - penaltyCount--; - } - } - - public void incrementWarningCount() { - warningCount++; - } - - public void decrementWarningCount() { - if (warningCount > 0) { - warningCount--; - } - } - - public boolean hasRole(Role role) { - return this.role == role; - } - - public Part getUserPart() { - return Part.valueOf(this.position.name()); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/UserCardinal.java b/src/main/java/com/weeth/domain/user/domain/entity/UserCardinal.java deleted file mode 100644 index aca38036..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/UserCardinal.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.weeth.domain.user.domain.entity; - -import jakarta.persistence.*; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class UserCardinal extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_cardinal_id") - private Long id; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - @ManyToOne - @JoinColumn(name = "cardinal_id") - private Cardinal cardinal; - - public UserCardinal(User user, Cardinal cardinal) { - this.user = user; - this.cardinal = cardinal; - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/CardinalStatus.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/CardinalStatus.java deleted file mode 100644 index 63b20855..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/CardinalStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -public enum CardinalStatus { - IN_PROGRESS, DONE -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/Department.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/Department.java deleted file mode 100644 index f166c7f7..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/Department.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -import com.weeth.domain.user.application.exception.DepartmentNotFoundException; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -import java.util.Arrays; - -@Getter -@RequiredArgsConstructor -public enum Department { - - SW("소프트웨어전공"), - AI("인공지능전공"), - COMPUTER_SCIENCE("컴퓨터공학과"), - INDUSTRIAL_ENGINEERING("산업공학과"), - VISUAL_DESIGN("시각디자인학과"), - BUSINESS("경영학과"), - ECONOMICS("경제학과"), - KOREAN_LANGUAGE("한국어문학과"), - URBAN_PLANNING("도시계획학전공"), - GLOBAL_BUSINESS("글로벌경영학과"), - FINANCIAL_MATHEMATICS("금융수학전공"), - HEALTHCARE_MANAGEMENT("의료산업경영학과"); // 더 필요한 학과는 추후 추가할 예정 - - private final String value; - - public static Department to(String before) { - return Arrays.stream(Department.values()) - .filter(department -> department.getValue().equals(before)) - .findAny() - .orElseThrow(DepartmentNotFoundException::new); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/LoginStatus.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/LoginStatus.java deleted file mode 100644 index b036d17e..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/LoginStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -public enum LoginStatus { - LOGIN, REGISTER, INTEGRATE -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/Position.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/Position.java deleted file mode 100644 index b9a391f6..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/Position.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -public enum Position { - D, - FE, - BE, - PM -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/Role.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/Role.java deleted file mode 100644 index c32bad1d..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/Role.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum Role { - USER, - ADMIN -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/Status.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/Status.java deleted file mode 100644 index 5950a5c7..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/Status.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -public enum Status { - WAITING, - ACTIVE, - BANNED, - LEFT -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/StatusPriority.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/StatusPriority.java deleted file mode 100644 index becc6b02..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/StatusPriority.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -import com.weeth.domain.user.application.exception.StatusNotFoundException; -import lombok.Getter; - -@Getter -public enum StatusPriority { - ACTIVE(1), - WAITING(2), - LEFT(3), - BANNED(4); - - private final int priority; - - StatusPriority(int priority) { - this.priority = priority; - } - - public static StatusPriority fromStatus(Status status) { - if (status == null) { - throw new StatusNotFoundException(); - } - return StatusPriority.valueOf(status.name()); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.java deleted file mode 100644 index 83b1d17f..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -public enum UsersOrderBy { - NAME_ASCENDING, // 이름순 정렬 - CARDINAL_DESCENDING; // 기수 기준으로 내림차순 정렬 -} diff --git a/src/main/java/com/weeth/domain/user/domain/repository/CardinalRepository.java b/src/main/java/com/weeth/domain/user/domain/repository/CardinalRepository.java deleted file mode 100644 index 47a29af5..00000000 --- a/src/main/java/com/weeth/domain/user/domain/repository/CardinalRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.user.domain.repository; - -import java.util.List; -import java.util.Optional; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.enums.CardinalStatus; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CardinalRepository extends JpaRepository { - - Optional findByCardinalNumber(Integer cardinal); - - Optional findByYearAndSemester(Integer year, Integer semester); - - List findAllByStatus(CardinalStatus cardinalStatus); - - Cardinal findFirstByStatusOrderByCardinalNumberDesc(CardinalStatus status); - - List findAllByOrderByCardinalNumberAsc(); - - List findAllByOrderByCardinalNumberDesc(); -} diff --git a/src/main/java/com/weeth/domain/user/domain/repository/UserCardinalRepository.java b/src/main/java/com/weeth/domain/user/domain/repository/UserCardinalRepository.java deleted file mode 100644 index 98024774..00000000 --- a/src/main/java/com/weeth/domain/user/domain/repository/UserCardinalRepository.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.weeth.domain.user.domain.repository; - -import java.util.List; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface UserCardinalRepository extends JpaRepository { - - List findAllByUserOrderByCardinalCardinalNumberDesc(User user); - - @Query("SELECT uc FROM UserCardinal uc WHERE uc.user IN :users ORDER BY uc.user.id, uc.cardinal.cardinalNumber DESC") - List findAllByUsers(List users); - - List findAllByOrderByUser_NameAsc(); - - @Query(""" - select uc.cardinal.cardinalNumber - from UserCardinal uc - where uc.user = :user - order by uc.cardinal.cardinalNumber desc - """) - List findCardinalNumbersByUser(@Param("user") User user); -} diff --git a/src/main/java/com/weeth/domain/user/domain/repository/UserRepository.java b/src/main/java/com/weeth/domain/user/domain/repository/UserRepository.java deleted file mode 100644 index ea074aae..00000000 --- a/src/main/java/com/weeth/domain/user/domain/repository/UserRepository.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.weeth.domain.user.domain.repository; - -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Status; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -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 UserRepository extends JpaRepository { - - Optional findByEmail(String email); - - Optional findByKakaoId(long kakaoId); - - Optional findByAppleId(String appleId); - - ListfindAllByNameContainingAndStatus(String name, Status status); - - boolean existsByEmail(String email); - - boolean existsByStudentId(String studentId); - - boolean existsByTel(String tel); - - boolean existsByStudentIdAndIdIsNot(String studentId, Long id); - - boolean existsByTelAndIdIsNot(String tel, Long id); - - List findAllByStatusOrderByName(Status status); - - List findAllByOrderByNameAsc(); - - @Query("SELECT uc.user FROM UserCardinal uc WHERE uc.cardinal = :cardinal AND uc.user.status = :status") - List findAllByCardinalAndStatus(@Param("cardinal") Cardinal cardinal, @Param("status") Status status); - - /* - todo 차후 리팩토링 - */ - @Query(""" - SELECT u - FROM User u - JOIN UserCardinal uc ON u.id = uc.user.id - JOIN uc.cardinal c - WHERE u.status = :status - GROUP BY u.id - ORDER BY MAX(c.cardinalNumber) DESC, u.name ASC - """) - Slice findAllByStatusOrderedByCardinalAndName(@Param("status") Status status, Pageable pageable); - - @Query(""" - SELECT u FROM User u - JOIN UserCardinal uc ON uc.user.id = u.id - WHERE u.status = :status - AND uc.cardinal = :cardinal - ORDER BY u.name ASC - """) - Slice findAllByCardinalOrderByNameAsc(@Param("status") Status status, @Param("cardinal") Cardinal cardinal, Pageable pageable); -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/CardinalGetService.java b/src/main/java/com/weeth/domain/user/domain/service/CardinalGetService.java deleted file mode 100644 index 14248651..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/CardinalGetService.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import java.util.List; -import com.weeth.domain.user.application.exception.CardinalNotFoundException; -import com.weeth.domain.user.application.exception.DuplicateCardinalException; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.enums.CardinalStatus; -import com.weeth.domain.user.domain.repository.CardinalRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class CardinalGetService { - - private final CardinalRepository cardinalRepository; - - public Cardinal findByAdminSide(Integer cardinal) { - return cardinalRepository.findByCardinalNumber(cardinal) - .orElseGet(() -> cardinalRepository.save(Cardinal.builder().cardinalNumber(cardinal).build())); - } - - public Cardinal findByUserSide(Integer cardinal) { - return cardinalRepository.findByCardinalNumber(cardinal) - .orElseThrow(CardinalNotFoundException::new); - } - - public Cardinal find(Integer year, Integer semester) { - return cardinalRepository.findByYearAndSemester(year, semester) - .orElseThrow(CardinalNotFoundException::new); - } - - public Cardinal findById(long cardinalId) { - return cardinalRepository.findById(cardinalId) - .orElseThrow(CardinalNotFoundException::new); - } - - public List findAll() { - return cardinalRepository.findAllByOrderByCardinalNumberAsc(); - } - - public List findAllCardinalNumberDesc() { - return cardinalRepository.findAllByOrderByCardinalNumberDesc(); - } - - public List findInProgress() { - return cardinalRepository.findAllByStatus(CardinalStatus.IN_PROGRESS); - } - - public void validateCardinal(Integer cardinal) { - if (cardinalRepository.findByCardinalNumber(cardinal).isPresent()) { - throw new DuplicateCardinalException(); - } - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/CardinalSaveService.java b/src/main/java/com/weeth/domain/user/domain/service/CardinalSaveService.java deleted file mode 100644 index 2e755d2f..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/CardinalSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.repository.CardinalRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class CardinalSaveService { - - private final CardinalRepository cardinalRepository; - - public Cardinal save(Cardinal cardinal) { - return cardinalRepository.save(cardinal); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserCardinalGetService.java b/src/main/java/com/weeth/domain/user/domain/service/UserCardinalGetService.java deleted file mode 100644 index 383a3ed3..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserCardinalGetService.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import java.util.Comparator; -import java.util.List; -import com.weeth.domain.user.application.exception.CardinalNotFoundException; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.repository.UserCardinalRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserCardinalGetService { - - private final UserCardinalRepository userCardinalRepository; - - public List getUserCardinals(User user) { - return userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user); - } - - public List findAll() { - return userCardinalRepository.findAllByOrderByUser_NameAsc(); - } - - public List findAll(List users) { - return userCardinalRepository.findAllByUsers(users); - } - - public boolean notContains(User user, Cardinal cardinal) { - return getUserCardinals(user).stream() - .noneMatch(userCardinal -> userCardinal.getCardinal().equals(cardinal)); - } - - public boolean isCurrent(User user, Cardinal cardinal) { - Integer maxCardinalNumber = getUserCardinals(user).stream() - .map(UserCardinal::getCardinal) - .map(Cardinal::getCardinalNumber) - .max(Integer::compareTo) - .orElseThrow(CardinalNotFoundException::new); - - return maxCardinalNumber < cardinal.getCardinalNumber(); - } - - public Cardinal getCurrentCardinal(User user) { - return getUserCardinals(user).stream() - .map(UserCardinal::getCardinal) - .max(Comparator.comparing(Cardinal::getCardinalNumber)) - .orElseThrow(CardinalNotFoundException::new); - } - - public List getCardinalNumbers(User user) { - return userCardinalRepository.findCardinalNumbersByUser(user); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserCardinalSaveService.java b/src/main/java/com/weeth/domain/user/domain/service/UserCardinalSaveService.java deleted file mode 100644 index 83a99999..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserCardinalSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.repository.UserCardinalRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserCardinalSaveService { - - private final UserCardinalRepository userCardinalRepository; - - public void save(UserCardinal userCardinal) { - userCardinalRepository.save(userCardinal); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserDeleteService.java b/src/main/java/com/weeth/domain/user/domain/service/UserDeleteService.java deleted file mode 100644 index 2f5dd11e..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserDeleteService.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.user.domain.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserDeleteService { - - @Transactional - public void leave(User user) { - user.leave(); - } - - @Transactional - public void ban(User user) { - user.ban(); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserGetService.java b/src/main/java/com/weeth/domain/user/domain/service/UserGetService.java deleted file mode 100644 index 3e2b2787..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserGetService.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Status; -import com.weeth.domain.user.domain.repository.UserRepository; -import com.weeth.domain.user.application.exception.UserNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; - -@Service -@RequiredArgsConstructor -public class UserGetService { - - private final UserRepository userRepository; - - public User find(Long userId) { - return userRepository.findById(userId) - .orElseThrow(UserNotFoundException::new); - } - - public User find(String email){ - return userRepository.findByEmail(email) - .orElseThrow(UserNotFoundException::new); - } - - public Optional findByKakaoId(long kakaoId){ - return userRepository.findByKakaoId(kakaoId); - } - - public Optional findByAppleId(String appleId){ - return userRepository.findByAppleId(appleId); - } - - public List search(String keyword) { - return userRepository.findAllByNameContainingAndStatus(keyword, Status.ACTIVE); - } - - public Boolean check(String email) { - return !userRepository.existsByEmail(email); - } - - public List findAll(List userId) { - return userRepository.findAllById(userId); - } - - public List findAllByCardinal(Cardinal cardinal) { - return userRepository.findAllByCardinalAndStatus(cardinal, Status.ACTIVE); - } - - public Slice findAll(Pageable pageable) { - Slice users = userRepository.findAllByStatusOrderedByCardinalAndName(Status.ACTIVE, pageable); - - if (users.isEmpty()) { - throw new UserNotFoundException(); - } - - return users; - } - - public Slice findAll(Pageable pageable, Cardinal cardinal) { - Slice users = userRepository.findAllByCardinalOrderByNameAsc(Status.ACTIVE, cardinal, pageable); - - if (users.isEmpty()) { - throw new UserNotFoundException(); - } - - return users; - } - - public boolean validateStudentId(String studentId) { - return userRepository.existsByStudentId(studentId); - } - - public boolean validateStudentId(String studentId, Long userId) { - return userRepository.existsByStudentIdAndIdIsNot(studentId, userId); - } - - public boolean validateTel(String tel) { - return userRepository.existsByTel(tel); - } - - public boolean validateTel(String tel, Long userId) { - return userRepository.existsByTelAndIdIsNot(tel, userId); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserSaveService.java b/src/main/java/com/weeth/domain/user/domain/service/UserSaveService.java deleted file mode 100644 index 7afd4987..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserSaveService { - - private final UserRepository userRepository; - - public void save(User user) { - userRepository.save(user); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserUpdateService.java b/src/main/java/com/weeth/domain/user/domain/service/UserUpdateService.java deleted file mode 100644 index 2f190ed6..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserUpdateService.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.user.domain.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.Update; - - -@Service -@Transactional -@RequiredArgsConstructor -public class UserUpdateService { - - public void update(User user, Update dto) { - user.update(dto); - } - - public void accept(User user) { - user.accept(); - } - - public void update(User user, String role) { - user.update(role); - } - - public void reset(User user, PasswordEncoder passwordEncoder) { - user.reset(passwordEncoder); - } -} diff --git a/src/main/java/com/weeth/domain/user/presentation/CardinalController.java b/src/main/java/com/weeth/domain/user/presentation/CardinalController.java deleted file mode 100644 index cd2c3ad9..00000000 --- a/src/main/java/com/weeth/domain/user/presentation/CardinalController.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.weeth.domain.user.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.user.application.dto.request.CardinalSaveRequest; -import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest; -import com.weeth.domain.user.application.dto.response.CardinalResponse; -import com.weeth.domain.user.application.exception.UserErrorCode; -import com.weeth.domain.user.application.usecase.CardinalUseCase; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static com.weeth.domain.user.presentation.UserResponseCode.*; - -@Tag(name = "CARDINAL") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1") -@ApiErrorCodeExample({UserErrorCode.class, JwtErrorCode.class}) -public class CardinalController { - - private final CardinalUseCase cardinalUseCase; - - @GetMapping("/cardinals") - @Operation(summary = "현재 저장된 기수 목록 조회 API") - public CommonResponse> findAllCardinals() { - List response = cardinalUseCase.findAll(); - - return CommonResponse.success(CARDINAL_FIND_ALL_SUCCESS, response); - } - - @PatchMapping("/admin/cardinals") - @Operation(summary = "[admin] 기수 정보 수정 API") - public CommonResponse updateCardinals(@RequestBody CardinalUpdateRequest dto) { - cardinalUseCase.update(dto); - - return CommonResponse.success(CARDINAL_UPDATE_SUCCESS); - } - - @PostMapping("/admin/cardinals") - @Operation(summary = "[admin] 새로운 기수 정보 저장 API") - public CommonResponse save(@RequestBody @Valid CardinalSaveRequest dto) { - cardinalUseCase.save(dto); - - return CommonResponse.success(CARDINAL_SAVE_SUCCESS); - } - -} diff --git a/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java b/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java deleted file mode 100644 index 04d593cf..00000000 --- a/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.weeth.domain.user.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.user.application.exception.UserErrorCode; -import com.weeth.domain.user.application.usecase.UserManageUseCase; -import com.weeth.domain.user.domain.entity.enums.UsersOrderBy; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.AdminResponse; -import static com.weeth.domain.user.presentation.UserResponseCode.*; - -@Tag(name = "USER ADMIN", description = "[ADMIN] 사용자 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/users") -@ApiErrorCodeExample({UserErrorCode.class, JwtErrorCode.class}) -public class UserAdminController { - - private final UserManageUseCase userManageUseCase; - - @GetMapping("/all") - @Operation(summary = "어드민용 회원 조회") - public CommonResponse> findAll(@RequestParam UsersOrderBy orderBy) { - return CommonResponse.success(USER_FIND_ALL_SUCCESS, userManageUseCase.findAllByAdmin(orderBy)); - } - - @PatchMapping - @Operation(summary = "가입 신청 승인") - public CommonResponse accept(@RequestBody UserId userId) { - userManageUseCase.accept(userId); - return CommonResponse.success(USER_ACCEPT_SUCCESS); - } - - @DeleteMapping - @Operation(summary = "유저 추방") - public CommonResponse ban(@RequestBody UserId userId) { - userManageUseCase.ban(userId); - return CommonResponse.success(USER_BAN_SUCCESS); - } - - @PatchMapping("/role") - @Operation(summary = "관리자로 승격/강등") - public CommonResponse update(@RequestBody List request) { - userManageUseCase.update(request); - return CommonResponse.success(USER_ROLE_UPDATE_SUCCESS); - } - - @PatchMapping("/apply") - @Operation(summary = "다음 기수도 이어서 진행") - public CommonResponse applyOB(@RequestBody List request) { - userManageUseCase.applyOB(request); - return CommonResponse.success(USER_APPLY_OB_SUCCESS); - } - - @PatchMapping("/reset") - @Operation(summary = "회원 비밀번호 초기화") - public CommonResponse resetPassword(@RequestBody UserId userId) { - userManageUseCase.reset(userId); - return CommonResponse.success(USER_PASSWORD_RESET_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/user/presentation/UserController.java b/src/main/java/com/weeth/domain/user/presentation/UserController.java deleted file mode 100644 index 05df410e..00000000 --- a/src/main/java/com/weeth/domain/user/presentation/UserController.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.weeth.domain.user.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.user.application.dto.response.UserResponseDto; -import com.weeth.domain.user.application.dto.response.UserResponseDto.SummaryResponse; -import com.weeth.domain.user.application.dto.response.UserResponseDto.UserResponse; -import com.weeth.domain.user.application.exception.UserErrorCode; -import com.weeth.domain.user.application.usecase.UserManageUseCase; -import com.weeth.domain.user.application.usecase.UserUseCase; -import com.weeth.domain.user.domain.service.UserGetService; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Slice; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.Response; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.SocialLoginResponse; -import static com.weeth.domain.user.presentation.UserResponseCode.*; - -@Tag(name = "USER", description = "사용자 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/users") -@ApiErrorCodeExample({UserErrorCode.class, JwtErrorCode.class}) -public class UserController { - - private final UserUseCase userUseCase; - private final UserManageUseCase userManageUseCase; - private final UserGetService userGetService; - - @PostMapping("/kakao/login") - @Operation(summary = "카카오 소셜 로그인 API") - public CommonResponse login(@RequestBody @Valid Login dto) { - SocialLoginResponse response = userUseCase.login(dto); - return CommonResponse.success(SOCIAL_LOGIN_SUCCESS, response); - } - - @PostMapping("/kakao/auth") - @Operation(summary = "카카오 소셜 회원가입 전 요청 API (미사용 API)") - public CommonResponse beforeRegister(@RequestBody @Valid Login dto) { - UserResponseDto.SocialAuthResponse response = userUseCase.authenticate(dto); - return CommonResponse.success(SOCIAL_AUTH_SUCCESS, response); - } - - @PostMapping("/apply") - @Operation(summary = "동아리 지원 신청. 현재 사용하지 않으므로 회원가입 시 /kakao/register api로 요청 바람") - public CommonResponse apply(@RequestBody @Valid SignUp dto) { - userUseCase.apply(dto); - return CommonResponse.success(USER_APPLY_SUCCESS); - } - - @PostMapping("/kakao/register") - @Operation(summary = "소셜 회원가입") - public CommonResponse register(@RequestBody @Valid Register dto) { - userUseCase.socialRegister(dto); - return CommonResponse.success(USER_APPLY_SUCCESS); - } - - @PatchMapping("/kakao/link") - @Operation(summary = "카카오 소셜 로그인 연동") - public CommonResponse integrate(@RequestBody @Valid NormalLogin dto) { - return CommonResponse.success(SOCIAL_INTEGRATE_SUCCESS, userUseCase.integrate(dto)); - } - - @PostMapping("/apple/login") - @Operation(summary = "애플 소셜 로그인 API") - public CommonResponse appleLogin(@RequestBody @Valid Login dto) { - SocialLoginResponse response = userUseCase.appleLogin(dto); - return CommonResponse.success(SOCIAL_LOGIN_SUCCESS, response); - } - - @PostMapping("/apple/register") - @Operation(summary = "애플 소셜 회원가입 (dev 전용 - 바로 ACTIVE)") - public CommonResponse appleRegister(@RequestBody @Valid Register dto) { - userUseCase.appleRegister(dto); - return CommonResponse.success(USER_APPLY_SUCCESS); - } - - @GetMapping("/email") - @Operation(summary = "이메일 중복 확인") - public CommonResponse checkEmail(@RequestParam String email) { - return CommonResponse.success(USER_EMAIL_CHECK_SUCCESS, userGetService.check(email)); - } - - @GetMapping("/all") - @Operation(summary = "동아리 멤버 전체 조회(전체/기수별)") - public CommonResponse> findAllUser(@RequestParam("pageNumber") int pageNumber, - @RequestParam("pageSize") int pageSize, - @RequestParam(required = false) Integer cardinal) { - return CommonResponse.success(USER_FIND_ALL_SUCCESS, userUseCase.findAllUser(pageNumber, pageSize, cardinal)); - } - - @GetMapping("/search") - @Operation(summary = "동아리 멤버 검색") - public CommonResponse> searchUser(@RequestParam String keyword) { - return CommonResponse.success(USER_FIND_BY_ID_SUCCESS, userUseCase.searchUser(keyword)); - } - - @GetMapping("/details") - @Operation(summary = "특정 멤버 상세 조회") - public CommonResponse findUser(@RequestParam Long userId) { - return CommonResponse.success( - USER_DETAILS_SUCCESS, userUseCase.findUserDetails(userId) - ); - } - - @GetMapping - @Operation(summary = "내 정보 조회") - public CommonResponse find(@Parameter(hidden = true) @CurrentUser Long userId) { - return CommonResponse.success(USER_FIND_BY_ID_SUCCESS, userUseCase.find(userId)); - } - - @GetMapping("/info") - @Operation(summary = "전역 내 정보 조회 API") - public CommonResponse findMyInfo(@Parameter(hidden = true) @CurrentUser Long userId) { - return CommonResponse.success(USER_FIND_BY_ID_SUCCESS, userUseCase.findUserInfo(userId)); - } - - @PatchMapping - @Operation(summary = "내 정보 수정") - public CommonResponse update(@RequestBody @Valid Update dto, @Parameter(hidden = true) @CurrentUser Long userId) { - userUseCase.update(dto, userId); - return CommonResponse.success(USER_UPDATE_SUCCESS); - } - - @DeleteMapping - @Operation(summary = "동아리 탈퇴") - public CommonResponse leave(@Parameter(hidden = true) @CurrentUser Long userId) { - userManageUseCase.leave(userId); - return CommonResponse.success(USER_LEAVE_SUCCESS); - } - - @PostMapping("/refresh") - @Operation(summary = "JWT 토큰 재발급 API") - public CommonResponse refresh(@Parameter(hidden = true) @RequestHeader("Authorization_refresh") String refreshToken) { - return CommonResponse.success(JWT_REFRESH_SUCCESS, userUseCase.refresh(refreshToken)); - } -} diff --git a/src/main/java/com/weeth/domain/user/presentation/UserResponseCode.java b/src/main/java/com/weeth/domain/user/presentation/UserResponseCode.java deleted file mode 100644 index 95beccc9..00000000 --- a/src/main/java/com/weeth/domain/user/presentation/UserResponseCode.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.weeth.domain.user.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum UserResponseCode implements ResponseCodeInterface { - // UserAdminController 관련 - USER_FIND_ALL_SUCCESS(1800, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), - USER_DETAILS_SUCCESS(1801, HttpStatus.OK, "특정 회원의 상세 정보를 성공적으로 조회했습니다."), - USER_ACCEPT_SUCCESS(1802, HttpStatus.OK, "회원 가입 승인이 성공적으로 처리되었습니다."), - USER_BAN_SUCCESS(1803, HttpStatus.OK, "회원이 성공적으로 차단되었습니다."), - USER_ROLE_UPDATE_SUCCESS(1804, HttpStatus.OK, "회원의 역할이 성공적으로 수정되었습니다."), - USER_APPLY_OB_SUCCESS(1805, HttpStatus.OK, "OB 신청이 성공적으로 처리되었습니다."), - USER_PASSWORD_RESET_SUCCESS(1806, HttpStatus.OK, "비밀번호가 성공적으로 초기화되었습니다."), - // UserController 관련 - USER_APPLY_SUCCESS(1807, HttpStatus.OK, "회원 가입 신청이 성공적으로 처리되었습니다."), - USER_EMAIL_CHECK_SUCCESS(1808, HttpStatus.OK, "이메일 중복 검사가 성공적으로 처리되었습니다."), - USER_FIND_BY_ID_SUCCESS(1809, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), - USER_UPDATE_SUCCESS(1810, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), - USER_LEAVE_SUCCESS(1811, HttpStatus.OK, "회원 탈퇴가 성공적으로 처리되었습니다."), - SOCIAL_LOGIN_SUCCESS(1812, HttpStatus.OK, "소셜 로그인에 성공했습니다."), - SOCIAL_REGISTER_SUCCESS(1813, HttpStatus.OK, "소셜 회원가입에 성공했습니다."), - SOCIAL_AUTH_SUCCESS(1814, HttpStatus.OK, "소셜 인증에 성공했습니다."), - SOCIAL_INTEGRATE_SUCCESS(1815, HttpStatus.OK, "소셜 로그인 연동에 성공했습니다."), - JWT_REFRESH_SUCCESS(1816, HttpStatus.OK, "토큰 재발급에 성공했습니다."), - - // CardinalController 관련 - CARDINAL_FIND_ALL_SUCCESS(1817, HttpStatus.OK, "전체 기수 조회에 성공했습니다."), - CARDINAL_SAVE_SUCCESS(1818, HttpStatus.OK, "기수 저장에 성공했습니다."), - CARDINAL_UPDATE_SUCCESS(1819, HttpStatus.OK, "기수 수정에 성공했습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - UserResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/java/com/weeth/global/auth/annotation/CurrentUser.java b/src/main/java/com/weeth/global/auth/annotation/CurrentUser.java deleted file mode 100644 index 8b37b036..00000000 --- a/src/main/java/com/weeth/global/auth/annotation/CurrentUser.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.global.auth.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.PARAMETER}) -@Retention(RetentionPolicy.RUNTIME) -public @interface CurrentUser { -} diff --git a/src/main/java/com/weeth/global/auth/apple/AppleAuthService.java b/src/main/java/com/weeth/global/auth/apple/AppleAuthService.java deleted file mode 100644 index 4e46af42..00000000 --- a/src/main/java/com/weeth/global/auth/apple/AppleAuthService.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.weeth.global.auth.apple; - -import com.weeth.global.auth.apple.dto.ApplePublicKey; -import com.weeth.global.auth.apple.dto.ApplePublicKeys; -import com.weeth.global.auth.apple.dto.AppleTokenResponse; -import com.weeth.global.auth.apple.dto.AppleUserInfo; -import com.weeth.global.auth.apple.exception.AppleAuthenticationException; -import com.weeth.global.config.properties.OAuthProperties; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; - -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.spec.RSAPublicKeySpec; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Base64; -import java.util.Date; -import java.util.Map; - -@Service -@Slf4j -public class AppleAuthService { - - private final OAuthProperties.AppleProperties appleProperties; - private final RestClient restClient = RestClient.create(); - - public AppleAuthService(OAuthProperties oAuthProperties) { - this.appleProperties = oAuthProperties.getApple(); - } - - // todo: 성능 개선 (캐싱 등) - - /** - * Authorization code로 애플 토큰 요청 - * client_secret은 JWT로 생성 (ES256 알고리즘) - */ - public AppleTokenResponse getAppleToken(String authCode) { - String clientSecret = generateClientSecret(); - - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("grant_type", "authorization_code"); - body.add("client_id", appleProperties.getClientId()); - body.add("client_secret", clientSecret); - body.add("code", authCode); - body.add("redirect_uri", appleProperties.getRedirectUri()); - - return restClient.post() - .uri(appleProperties.getTokenUri()) - .body(body) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .retrieve() - .body(AppleTokenResponse.class); - } - - /** - * ID Token 검증 및 사용자 정보 추출 - * 애플은 별도 userInfo 엔드포인트가 없고 ID Token에 정보가 포함됨 - */ - public AppleUserInfo verifyAndDecodeIdToken(String idToken) { - try { - // 1. ID Token의 헤더에서 kid 추출 - String[] tokenParts = idToken.split("\\."); - String header = new String(Base64.getUrlDecoder().decode(tokenParts[0])); - Map headerMap = parseJson(header); - String kid = (String) headerMap.get("kid"); - - // 2. 애플 공개키 가져오기 - ApplePublicKeys publicKeys = restClient.get() - .uri(appleProperties.getKeysUri()) - .retrieve() - .body(ApplePublicKeys.class); - - // 3. kid와 일치하는 공개키 찾기 - ApplePublicKey matchedKey = publicKeys.keys().stream() - .filter(key -> key.kid().equals(kid)) - .findFirst() - .orElseThrow(AppleAuthenticationException::new); - - // 4. 공개키로 ID Token 검증 - PublicKey publicKey = generatePublicKey(matchedKey); - // JJWT 0.13.0+ uses parser() instead of parserBuilder() - Claims claims = Jwts.parser() - .verifyWith(publicKey) - .build() - .parseSignedClaims(idToken) - .getPayload(); - - // 5. Claims 검증 - validateClaims(claims); - - // 6. 사용자 정보 추출 - String appleId = claims.getSubject(); - String email = claims.get("email", String.class); - Boolean emailVerified = claims.get("email_verified", Boolean.class); - - return AppleUserInfo.builder() - .appleId(appleId) - .email(email) - .emailVerified(emailVerified != null ? emailVerified : false) - .build(); - - } catch (Exception e) { - log.error("애플 ID Token 검증 실패", e); - throw new AppleAuthenticationException(); - } - } - - /** - * 애플 로그인용 client_secret 생성 - * ES256 알고리즘으로 JWT 생성 (p8 키 파일 사용) - */ - private String generateClientSecret() { - try (InputStream inputStream = getInputStream(appleProperties.getPrivateKeyPath())) { - // p8 파일에서 Private Key 읽기 - String privateKeyContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - - // PEM 형식의 헤더/푸터 제거 - privateKeyContent = privateKeyContent - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replaceAll("\\s", ""); - - // Private Key 객체 생성 - byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent); - KeyFactory keyFactory = KeyFactory.getInstance("EC"); - PrivateKey privateKey = keyFactory.generatePrivate( - new java.security.spec.PKCS8EncodedKeySpec(keyBytes) - ); - - // JWT 생성 - LocalDateTime now = LocalDateTime.now(); - Date issuedAt = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); - Date expiration = Date.from(now.plusMonths(5).atZone(ZoneId.systemDefault()).toInstant()); - - return Jwts.builder() - .setHeaderParam("kid", appleProperties.getKeyId()) - .setHeaderParam("alg", "ES256") - .setIssuer(appleProperties.getTeamId()) - .setIssuedAt(issuedAt) - .setExpiration(expiration) - .setAudience("https://appleid.apple.com") - .setSubject(appleProperties.getClientId()) - .signWith(privateKey, SignatureAlgorithm.ES256) - .compact(); - - } catch (Exception e) { - log.error("애플 Client Secret 생성 실패", e); - throw new AppleAuthenticationException(); - } - } - - /** - * 파일 경로에서 InputStream 가져오기 - * 절대 경로면 파일 시스템에서, 상대 경로면 classpath에서 읽음 - */ - private InputStream getInputStream(String path) throws IOException { - // 절대 경로인 경우 파일 시스템에서 읽기 - if (path.startsWith("/") || path.matches("^[A-Za-z]:.*")) { - return new FileInputStream(path); - } - // 상대 경로는 classpath에서 읽기 - return new ClassPathResource(path).getInputStream(); - } - - /** - * 애플 공개키로부터 PublicKey 객체 생성 - */ - private PublicKey generatePublicKey(ApplePublicKey applePublicKey) { - try { - byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n()); - byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e()); - - BigInteger n = new BigInteger(1, nBytes); - BigInteger e = new BigInteger(1, eBytes); - - RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); - KeyFactory keyFactory = KeyFactory.getInstance("RSA"); - - return keyFactory.generatePublic(publicKeySpec); - } catch (Exception ex) { - log.error("애플 공개키 생성 실패", ex); - throw new AppleAuthenticationException(); - } - } - - /** - * ID Token의 Claims 검증 - */ - private void validateClaims(Claims claims) { - String iss = claims.getIssuer(); - // JJWT 0.13.0+ returns Set for getAudience() - var audSet = claims.getAudience(); - String aud = audSet.iterator().hasNext() ? audSet.iterator().next() : null; - - if (!iss.equals("https://appleid.apple.com")) { - throw new RuntimeException("유효하지 않은 발급자(issuer)입니다."); - } - - // audience가 clientId와 일치하는지 확인 - if (aud == null || !aud.equals(appleProperties.getClientId())) { - log.error("유효하지 않은 audience: {}. 기대값: {}", aud, appleProperties.getClientId()); - throw new RuntimeException("유효하지 않은 수신자(audience)입니다."); - } - - Date expiration = claims.getExpiration(); - if (expiration.before(new Date())) { - throw new RuntimeException("만료된 ID Token입니다."); - } - } - - /** - * JSON 문자열을 Map으로 파싱 - */ - @SuppressWarnings("unchecked") - private Map parseJson(String json) { - try { - com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); - return objectMapper.readValue(json, Map.class); - } catch (Exception e) { - throw new RuntimeException("JSON 파싱 실패"); - } - } -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKey.java b/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKey.java deleted file mode 100644 index b84cfb3b..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKey.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public record ApplePublicKey( - String kty, - String kid, - String use, - String alg, - String n, - String e -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKeys.java b/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKeys.java deleted file mode 100644 index 6c247f5a..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKeys.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -import java.util.List; - -public record ApplePublicKeys( - List keys -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/AppleTokenResponse.java b/src/main/java/com/weeth/global/auth/apple/dto/AppleTokenResponse.java deleted file mode 100644 index 31944ec5..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/AppleTokenResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -public record AppleTokenResponse( - String access_token, - String token_type, - Long expires_in, - String refresh_token, - String id_token -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/AppleUserInfo.java b/src/main/java/com/weeth/global/auth/apple/dto/AppleUserInfo.java deleted file mode 100644 index 6f895fe9..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/AppleUserInfo.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -import lombok.Builder; - -@Builder -public record AppleUserInfo( - String appleId, - String email, - Boolean emailVerified -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/exception/AppleAuthenticationException.java b/src/main/java/com/weeth/global/auth/apple/exception/AppleAuthenticationException.java deleted file mode 100644 index 0ad880ed..00000000 --- a/src/main/java/com/weeth/global/auth/apple/exception/AppleAuthenticationException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.apple.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AppleAuthenticationException extends BaseException { - public AppleAuthenticationException() { - super(401, "애플 로그인에 실패했습니다."); - } -} diff --git a/src/main/java/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.java b/src/main/java/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.java deleted file mode 100644 index cf605df0..00000000 --- a/src/main/java/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.weeth.global.auth.authentication; - -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.global.common.response.CommonResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.web.access.AccessDeniedHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Slf4j -@Component -public class CustomAccessDeniedHandler implements AccessDeniedHandler { - - @Override - public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { - setResponse(response); - log.error("ExceptionClass: {}, Message: {}", accessDeniedException.getClass().getSimpleName(), accessDeniedException.getMessage()); - } - - private void setResponse(HttpServletResponse response) throws IOException { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - - String message = new ObjectMapper().writeValueAsString(CommonResponse.createFailure(ErrorMessage.FORBIDDEN.getCode(), ErrorMessage.FORBIDDEN.getMessage())); - response.getWriter().write(message); - } -} diff --git a/src/main/java/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.java b/src/main/java/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.java deleted file mode 100644 index b4f8c552..00000000 --- a/src/main/java/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.weeth.global.auth.authentication; - -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.global.common.response.CommonResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Slf4j -@Component -public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { - setResponse(response); - log.error("ExceptionClass: {}, Message: {}", authException.getClass().getSimpleName(), authException.getMessage()); - } - - private void setResponse(HttpServletResponse response) throws IOException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - - String message = new ObjectMapper().writeValueAsString(CommonResponse.createFailure(ErrorMessage.UNAUTHORIZED.getCode(), ErrorMessage.UNAUTHORIZED.getMessage())); - response.getWriter().write(message); - } -} diff --git a/src/main/java/com/weeth/global/auth/authentication/ErrorMessage.java b/src/main/java/com/weeth/global/auth/authentication/ErrorMessage.java deleted file mode 100644 index 970d768c..00000000 --- a/src/main/java/com/weeth/global/auth/authentication/ErrorMessage.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.weeth.global.auth.authentication; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum ErrorMessage { - - UNAUTHORIZED(401, "인증 정보가 존재하지 않습니다."), - FORBIDDEN(403, "권한이 없습니다."), - SC_BAD_REQUEST_PROVIDER(400, "잘못된 provider 요청입니다."); - - private final int code; - private final String message; -} diff --git a/src/main/java/com/weeth/global/auth/jwt/application/dto/JwtDto.java b/src/main/java/com/weeth/global/auth/jwt/application/dto/JwtDto.java deleted file mode 100644 index e307c480..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/application/dto/JwtDto.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.global.auth.jwt.application.dto; - -public record JwtDto( - String accessToken, - String refreshToken -) { -} diff --git a/src/main/java/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.java b/src/main/java/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.java deleted file mode 100644 index 304d7631..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.weeth.global.auth.jwt.application.usecase; - -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.service.JwtProvider; -import com.weeth.global.auth.jwt.service.JwtRedisService; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.io.IOException; - -@Service -@RequiredArgsConstructor -public class JwtManageUseCase { - - private final JwtProvider jwtProvider; - private final JwtService jwtService; - private final JwtRedisService jwtRedisService; - - // 토큰 발급 - public JwtDto create(Long userId, String email, Role role){ - String accessToken = jwtProvider.createAccessToken(userId, email, role); - String refreshToken = jwtProvider.createRefreshToken(userId); - - updateToken(userId, refreshToken, role, email); - - return new JwtDto(accessToken, refreshToken); - } - - // 토큰 헤더로 전송 - public void sendToken(JwtDto dto, HttpServletResponse response) throws IOException { - jwtService.sendAccessAndRefreshToken(response, dto.accessToken(), dto.refreshToken()); - } - - // 토큰 재발급 - public JwtDto reIssueToken(String requestToken){ - jwtProvider.validate(requestToken); - - Long userId = jwtService.extractId(requestToken).get(); - - jwtRedisService.validateRefreshToken(userId, requestToken); - - Role role = jwtRedisService.getRole(userId); - String email = jwtRedisService.getEmail(userId); - - JwtDto token = create(userId, email, role); - jwtRedisService.set(userId, token.refreshToken(), role, email); - - return token; - } - - // 리프레시 토큰 업데이트 - private void updateToken(long userId, String refreshToken, Role role, String email){ - jwtRedisService.set(userId, refreshToken, role, email); - } - -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/AnonymousAuthenticationException.java b/src/main/java/com/weeth/global/auth/jwt/exception/AnonymousAuthenticationException.java deleted file mode 100644 index 37a858a4..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/AnonymousAuthenticationException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AnonymousAuthenticationException extends BaseException { - public AnonymousAuthenticationException() { - super(JwtErrorCode.ANONYMOUS_AUTHENTICATION); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/InvalidTokenException.java b/src/main/java/com/weeth/global/auth/jwt/exception/InvalidTokenException.java deleted file mode 100644 index 2eb97951..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/InvalidTokenException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class InvalidTokenException extends BaseException { - public InvalidTokenException() { - super(JwtErrorCode.INVALID_TOKEN); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/JwtErrorCode.java b/src/main/java/com/weeth/global/auth/jwt/exception/JwtErrorCode.java deleted file mode 100644 index 165a5149..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/JwtErrorCode.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum JwtErrorCode implements ErrorCodeInterface { - - @ExplainError("토큰의 구조가 올바르지 않거나(Malformed), 서명이 유효하지 않은 경우 발생합니다. 토큰을 재발급 받아주세요.") - INVALID_TOKEN(2900, HttpStatus.BAD_REQUEST, "올바르지 않은 Token 입니다."), - - @ExplainError("Redis에 해당 리프레시 토큰이 존재하지 않습니다. 토큰이 만료되었거나, 이미 로그아웃(삭제)된 상태일 수 있습니다. 다시 로그인해주세요.") - REDIS_TOKEN_NOT_FOUND(2901, HttpStatus.NOT_FOUND,"저장된 리프레시 토큰이 존재하지 않습니다."), - - @ExplainError("API 요청 헤더(Authorization)에 토큰 값이 포함되지 않았거나 비어있을 때 발생합니다.") - TOKEN_NOT_FOUND(2902, HttpStatus.NOT_FOUND, "헤더에서 토큰을 찾을 수 없습니다."), - - @ExplainError("인증이 필요한 리소스에 인증 정보 없이(Anonymous) 접근을 시도했을 때 발생합니다. (Spring Security 필터 단계 차단)") - ANONYMOUS_AUTHENTICATION(2903, HttpStatus.UNAUTHORIZED, "인증정보가 존재하지 않습니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/RedisTokenNotFoundException.java b/src/main/java/com/weeth/global/auth/jwt/exception/RedisTokenNotFoundException.java deleted file mode 100644 index 8fdd5e86..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/RedisTokenNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class RedisTokenNotFoundException extends BaseException { - public RedisTokenNotFoundException() { - super(JwtErrorCode.REDIS_TOKEN_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/TokenNotFoundException.java b/src/main/java/com/weeth/global/auth/jwt/exception/TokenNotFoundException.java deleted file mode 100644 index 8f798861..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/TokenNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class TokenNotFoundException extends BaseException { - public TokenNotFoundException() { - super(JwtErrorCode.TOKEN_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java b/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java deleted file mode 100644 index c0ecba1f..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.weeth.global.auth.jwt.filter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.service.UserGetService; -import com.weeth.global.auth.jwt.exception.TokenNotFoundException; -import com.weeth.global.auth.jwt.service.JwtProvider; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -@RequiredArgsConstructor -@Slf4j -public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter { - - private static final String NO_CHECK_URL = "/api/v1/login"; - private final String DUMMY = "DUMMY_PASSWORD"; - - private final JwtProvider jwtProvider; - private final JwtService jwtService; - private final UserGetService userGetService; - - private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (request.getRequestURI().equals(NO_CHECK_URL)) { - filterChain.doFilter(request, response); - return; - } - // 유저 캐싱 도입 - try { - String accessToken = jwtService.extractAccessToken(request) - .orElseThrow(TokenNotFoundException::new); - if (jwtProvider.validate(accessToken)) { - saveAuthentication(accessToken); - } - } catch (TokenNotFoundException e) { - log.debug("Token not found: {}", e.getMessage()); - } catch (RuntimeException e) { - log.info("error token: {}", e.getMessage()); - } - - filterChain.doFilter(request, response); - - } - - public void saveAuthentication(String accessToken) { - - String email = jwtService.extractEmail(accessToken).get(); - Role role = Role.valueOf(jwtService.extractRole(accessToken).get()); - - UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder() - .username(email) - .password(DUMMY) - .roles(role.name()) - .build(); - - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetailsUser, null, - authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities())); - - SecurityContextHolder.getContext().setAuthentication(authentication); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/service/JwtProvider.java b/src/main/java/com/weeth/global/auth/jwt/service/JwtProvider.java deleted file mode 100644 index ca5e413d..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/service/JwtProvider.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.weeth.global.auth.jwt.service; - -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.jwt.exception.InvalidTokenException; -import com.weeth.global.config.properties.JwtProperties; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.Date; - -@Service -@Slf4j -public class JwtProvider { - - private static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; - private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; - private static final String EMAIL_CLAIM = "email"; - private static final String ID_CLAIM = "id"; - private static final String ROLE_CLAIM = "role"; - - private final SecretKey secretKey; - private final Long accessTokenExpirationPeriod; - private final Long refreshTokenExpirationPeriod; - - public JwtProvider(JwtProperties jwtProperties) { - this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getKey().getBytes(StandardCharsets.UTF_8)); - this.accessTokenExpirationPeriod = jwtProperties.getAccess().getExpiration(); - this.refreshTokenExpirationPeriod = jwtProperties.getRefresh().getExpiration(); - } - - - public String createAccessToken(Long id, String email, Role role) { - Date now = new Date(); - return Jwts.builder() - .subject(ACCESS_TOKEN_SUBJECT) - .claim(ID_CLAIM, id) - .claim(EMAIL_CLAIM, email) - .claim(ROLE_CLAIM, role.toString()) - .issuedAt(now) - .expiration(new Date(now.getTime() + accessTokenExpirationPeriod)) - .signWith(secretKey) - .compact(); - } - - public String createRefreshToken(Long id) { - Date now = new Date(); - return Jwts.builder() - .subject(REFRESH_TOKEN_SUBJECT) - .claim(ID_CLAIM, id) - .issuedAt(now) - .expiration(new Date(now.getTime() + refreshTokenExpirationPeriod)) - .signWith(secretKey) - .compact(); - } - - public boolean validate(String token) { - try { - Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token); - return true; - } catch (JwtException | IllegalArgumentException e) { - log.error("유효하지 않은 토큰입니다. {}", e.getMessage()); - throw new InvalidTokenException(); - } - } - - public Claims parseClaims(String token) { - try { - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .getPayload(); - } catch (JwtException | IllegalArgumentException e) { - log.error("토큰 파싱 실패: {}", e.getMessage()); - throw new InvalidTokenException(); - } - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/service/JwtRedisService.java b/src/main/java/com/weeth/global/auth/jwt/service/JwtRedisService.java deleted file mode 100644 index 90c021cf..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/service/JwtRedisService.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.weeth.global.auth.jwt.service; - -import com.weeth.domain.user.application.exception.EmailNotFoundException; -import com.weeth.domain.user.application.exception.RoleNotFoundException; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.jwt.exception.InvalidTokenException; -import com.weeth.global.auth.jwt.exception.RedisTokenNotFoundException; -import com.weeth.global.config.properties.JwtProperties; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Service; - -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -@Slf4j -@Service -@RequiredArgsConstructor -public class JwtRedisService { - - private static final String PREFIX = "refreshToken:"; - private static final String TOKEN = "token"; - private static final String ROLE = "role"; - private static final String EMAIL = "email"; - - private final JwtProperties jwtProperties; - private final RedisTemplate redisTemplate; - - public void set(long userId, String refreshToken, Role role, String email) { - String key = getKey(userId); - put(key, TOKEN, refreshToken); - put(key, ROLE, role.toString()); - put(key, EMAIL, email); - redisTemplate.expire(key, jwtProperties.getRefresh().getExpiration(), TimeUnit.MINUTES); - log.info("Refresh Token 저장/업데이트: {}", key); - } - - public void delete(Long userId) { - String key = getKey(userId); - redisTemplate.delete(key); - } - - public void validateRefreshToken(long userId, String requestToken) { - if (!find(userId).equals(requestToken)) { - throw new InvalidTokenException(); - } - } - - public String getEmail(long userId) { - String key = getKey(userId); - String roleValue = (String) redisTemplate.opsForHash().get(key, "email"); - - return Optional.ofNullable(roleValue) - .orElseThrow(EmailNotFoundException::new); - } - - public Role getRole(long userId) { - String key = getKey(userId); - String roleValue = (String) redisTemplate.opsForHash().get(key, "role"); - - return Optional.ofNullable(roleValue) - .map(Role::valueOf) - .orElseThrow(RoleNotFoundException::new); - } - - public void updateRole(long userId, String role) { - String key = getKey(userId); - - if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { - redisTemplate.opsForHash().put(key, "role", role); - } - } - - private String find(long userId) { - String key = getKey(userId); - return Optional.ofNullable((String) redisTemplate.opsForHash().get(key, "token")) - .orElseThrow(RedisTokenNotFoundException::new); - } - - private String getKey(long userId) { - return PREFIX + userId; - } - - private void put(String key, String hashKey, Object value) { - redisTemplate.opsForHash().put(key, hashKey, value); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/service/JwtService.java b/src/main/java/com/weeth/global/auth/jwt/service/JwtService.java deleted file mode 100644 index 40b9737d..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/service/JwtService.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.weeth.global.auth.jwt.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.exception.TokenNotFoundException; -import com.weeth.global.common.response.CommonResponse; -import com.weeth.global.config.properties.JwtProperties; -import io.jsonwebtoken.Claims; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.io.IOException; -import java.util.Optional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class JwtService { - - private static final String EMAIL_CLAIM = "email"; - private static final String ID_CLAIM = "id"; - private static final String ROLE_CLAIM = "role"; - private static final String BEARER = "Bearer "; - private static final String LOGIN_SUCCESS_MESSAGE = "자체 로그인 성공."; - - private final JwtProperties jwtProperties; - private final JwtProvider jwtProvider; - - public String extractRefreshToken(HttpServletRequest request) { - return Optional.ofNullable(request.getHeader(jwtProperties.getRefresh().getHeader())) - .filter(refreshToken -> refreshToken.startsWith(BEARER)) - .map(refreshToken -> refreshToken.replace(BEARER, "")) - .orElseThrow(TokenNotFoundException::new); - } - - public Optional extractAccessToken(HttpServletRequest request) { - return Optional.ofNullable(request.getHeader(jwtProperties.getAccess().getHeader())) - .filter(refreshToken -> refreshToken.startsWith(BEARER)) - .map(refreshToken -> refreshToken.replace(BEARER, "")); - } - - public Optional extractEmail(String accessToken) { - try { - Claims claims = jwtProvider.parseClaims(accessToken); - return Optional.ofNullable(claims.get(EMAIL_CLAIM, String.class)); - } catch (Exception e) { - log.error("액세스 토큰이 유효하지 않습니다."); - return Optional.empty(); - } - } - - public Optional extractId(String token) { - try { - Claims claims = jwtProvider.parseClaims(token); - return Optional.ofNullable(claims.get(ID_CLAIM, Long.class)); - } catch (Exception e) { - log.error("액세스 토큰이 유효하지 않습니다."); - return Optional.empty(); - } - } - - public Optional extractRole(String token) { - try { - Claims claims = jwtProvider.parseClaims(token); - return Optional.ofNullable(claims.get(ROLE_CLAIM, String.class)); - } catch (Exception e) { - log.error("액세스 토큰이 유효하지 않습니다."); - return Optional.empty(); - } - } - - // header -> body로 수정 - public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) throws IOException { - response.setStatus(HttpServletResponse.SC_OK); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - - String message = new ObjectMapper().writeValueAsString(CommonResponse.createSuccess(LOGIN_SUCCESS_MESSAGE, new JwtDto(accessToken, refreshToken))); - response.getWriter().write(message); - } - -} diff --git a/src/main/java/com/weeth/global/auth/kakao/KakaoAuthService.java b/src/main/java/com/weeth/global/auth/kakao/KakaoAuthService.java deleted file mode 100644 index de231ea3..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/KakaoAuthService.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.weeth.global.auth.kakao; - -import com.weeth.global.auth.kakao.dto.KakaoTokenResponse; -import com.weeth.global.auth.kakao.dto.KakaoUserInfoResponse; -import com.weeth.global.config.properties.OAuthProperties; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; - -@Service -@Slf4j -public class KakaoAuthService { - - private final OAuthProperties.KakaoProperties kakaoProperties; - private final RestClient restClient = RestClient.create(); - - public KakaoAuthService(OAuthProperties oAuthProperties) { - this.kakaoProperties = oAuthProperties.getKakao(); - } - - public KakaoTokenResponse getKakaoToken(String authCode) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("grant_type", kakaoProperties.getGrantType()); - body.add("client_id", kakaoProperties.getClientId()); - body.add("redirect_uri", kakaoProperties.getRedirectUri()); - body.add("code", authCode); - - return restClient.post() - .uri(kakaoProperties.getTokenUri()) - .body(body) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .retrieve() - .body(KakaoTokenResponse.class); - } - - public KakaoUserInfoResponse getUserInfo(String accessToken) { - return restClient.get() - .uri(kakaoProperties.getUserInfoUri()) - .header("Authorization", "Bearer " + accessToken) - .retrieve() - .body(KakaoUserInfoResponse.class); - - } -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccessToken.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccessToken.java deleted file mode 100644 index 21a18865..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccessToken.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoAccessToken ( - String accessToken -) { -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccount.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccount.java deleted file mode 100644 index 6aaaf0f4..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccount.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoAccount( - Boolean is_email_valid, - Boolean is_email_verified, - String email -) { -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.java deleted file mode 100644 index 9bc612de..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoTokenResponse( - String token_type, - String access_token, - Integer expires_in, - String refresh_token, - Integer refresh_token_expires_in -) { -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.java deleted file mode 100644 index e9e58760..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoUserInfoResponse( - Long id, - KakaoAccount kakao_account -) { -} diff --git a/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java b/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java deleted file mode 100644 index b4fb497d..00000000 --- a/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.weeth.global.auth.resolver; - -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.auth.jwt.exception.AnonymousAuthenticationException; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; -import org.springframework.core.MethodParameter; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -import java.util.Optional; - -@RequiredArgsConstructor -public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { - - private final JwtService jwtService; - - @Override - public boolean supportsParameter(MethodParameter parameter) { // parameter가 해당 resolver를 지원하는 여부 확인 - boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentUser.class); // @CurrentUser이 존재하는가? - boolean parameterType = Long.class.isAssignableFrom(parameter.getParameterType()); // 파라미터 타입이 Long을 상속하거나 구현하였는가? - return hasAnnotation && parameterType; // 둘 다 충족할 시 true - } - - @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 인증 객체 가져오기 - - if (authentication instanceof AnonymousAuthenticationToken) { // 익명 인증 토큰의 인스턴스라면 0 반환 - throw new AnonymousAuthenticationException(); - } - - String token = Optional.ofNullable(webRequest.getHeader("Authorization")) - .map(accessToken -> accessToken.replace("Bearer ", "")).get(); - - return jwtService.extractId(token).get(); // 토큰에서 userId 조회 - } -} \ No newline at end of file diff --git a/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java b/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java deleted file mode 100644 index 771f1457..00000000 --- a/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.weeth.global.common.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.account.application.exception.AccountErrorCode; -import com.weeth.domain.attendance.application.exception.AttendanceErrorCode; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.NoticeErrorCode; -import com.weeth.domain.board.application.exception.PostErrorCode; -import com.weeth.domain.comment.application.exception.CommentErrorCode; -import com.weeth.domain.penalty.application.exception.PenaltyErrorCode; -import com.weeth.domain.schedule.application.exception.EventErrorCode; -import com.weeth.domain.schedule.application.exception.MeetingErrorCode; -import com.weeth.domain.user.application.exception.UserErrorCode; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1/docs/exceptions") -@Tag(name = "Exception Document", description = "API 에러 코드 문서") -public class ExceptionDocController { - - @GetMapping("/account") - @Operation(summary = "Account 도메인 에러 코드 목록") - @ApiErrorCodeExample(AccountErrorCode.class) - public void accountErrorCodes() { - } - - @GetMapping("/attendance") - @Operation(summary = "Attendance 도메인 에러 코드 목록") - @ApiErrorCodeExample(AttendanceErrorCode.class) - public void attendanceErrorCodes() { - } - - @GetMapping("/board") - @Operation(summary = "Board 도메인 에러 코드 목록") - @ApiErrorCodeExample({BoardErrorCode.class, NoticeErrorCode.class, PostErrorCode.class, CommentErrorCode.class}) - public void boardErrorCodes() { - } - - @GetMapping("/penalty") - @Operation(summary = "Penalty 도메인 에러 코드 목록") - @ApiErrorCodeExample(PenaltyErrorCode.class) - public void penaltyErrorCodes() { - } - - @GetMapping("/schedule") - @Operation(summary = "Schedule 도메인 에러 코드 목록") - @ApiErrorCodeExample({EventErrorCode.class, MeetingErrorCode.class}) - public void scheduleErrorCodes() { - } - - @GetMapping("/user") - @Operation(summary = "User 도메인 에러 코드 목록") - @ApiErrorCodeExample(UserErrorCode.class) - public void userErrorCodes() { - } - - //todo: SAS 관련 예외도 추가 - @GetMapping("/auth") - @Operation(summary = "인증/인가 에러 코드 목록") - @ApiErrorCodeExample({JwtErrorCode.class}) - public void authErrorCodes() { - } -} diff --git a/src/main/java/com/weeth/global/common/controller/StatusCheckController.java b/src/main/java/com/weeth/global/common/controller/StatusCheckController.java deleted file mode 100644 index 6c88bbcb..00000000 --- a/src/main/java/com/weeth/global/common/controller/StatusCheckController.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.global.common.controller; - -import io.swagger.v3.oas.annotations.Hidden; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@Hidden -@RestController -public class StatusCheckController { - - @GetMapping("/health-check") - public ResponseEntity checkHealthStatus() { - - return new ResponseEntity<>(HttpStatus.OK); - } -} diff --git a/src/main/java/com/weeth/global/common/entity/BaseEntity.java b/src/main/java/com/weeth/global/common/entity/BaseEntity.java deleted file mode 100644 index a92a6187..00000000 --- a/src/main/java/com/weeth/global/common/entity/BaseEntity.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.weeth.global.common.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.EntityListeners; -import jakarta.persistence.MappedSuperclass; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.LocalDateTime; - -@Getter -@MappedSuperclass -@EntityListeners(AuditingEntityListener.class) -@SuperBuilder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class BaseEntity { - - @CreatedDate - @Column(updatable = false) - private LocalDateTime createdAt; - @LastModifiedDate - private LocalDateTime modifiedAt; -} \ No newline at end of file diff --git a/src/main/java/com/weeth/global/common/exception/ApiErrorCodeExample.java b/src/main/java/com/weeth/global/common/exception/ApiErrorCodeExample.java deleted file mode 100644 index dda006c6..00000000 --- a/src/main/java/com/weeth/global/common/exception/ApiErrorCodeExample.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.global.common.exception; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface ApiErrorCodeExample { - Class[] value(); -} diff --git a/src/main/java/com/weeth/global/common/exception/BaseException.java b/src/main/java/com/weeth/global/common/exception/BaseException.java deleted file mode 100644 index 9d810822..00000000 --- a/src/main/java/com/weeth/global/common/exception/BaseException.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.global.common.exception; - -import lombok.Getter; - -@Getter -public abstract class BaseException extends RuntimeException { - - private final int statusCode; - private final ErrorCodeInterface errorCode; - - public BaseException(int code, String message) { - super(message); - this.statusCode = code; - this.errorCode = null; - } - - public BaseException(ErrorCodeInterface errorCode) { - super(errorCode.getMessage()); - this.statusCode = errorCode.getStatus().value(); - this.errorCode = errorCode; - } -} diff --git a/src/main/java/com/weeth/global/common/exception/BindExceptionResponse.java b/src/main/java/com/weeth/global/common/exception/BindExceptionResponse.java deleted file mode 100644 index 572ed828..00000000 --- a/src/main/java/com/weeth/global/common/exception/BindExceptionResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.global.common.exception; - -import lombok.Builder; - -@Builder -public record BindExceptionResponse( - String message, - Object value -) { -} diff --git a/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java b/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java deleted file mode 100644 index e711dc88..00000000 --- a/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.weeth.global.common.exception; - -import com.weeth.global.common.response.CommonResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.BindException; -import org.springframework.web.ErrorResponse; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; - -import java.util.ArrayList; -import java.util.List; - -@Slf4j -@RestControllerAdvice -public class CommonExceptionHandler { - - private static final String INPUT_FORMAT_ERROR_MESSAGE = "입력 포맷이 올바르지 않습니다."; - private static final String LOG_FORMAT = "Class : {}, Code : {}, Message : {}"; - - @ExceptionHandler(BaseException.class) // 커스텀 예외 처리 - public ResponseEntity> handle(BaseException ex) { - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), ex.getStatusCode(), ex.getMessage()); - - CommonResponse response = CommonResponse.createFailure(ex.getStatusCode(), ex.getMessage()); - - return ResponseEntity - .status(ex.getStatusCode()) - .body(response); - } - - @ExceptionHandler(BindException.class) // BindException == @ModelAttribute 어노테이션으로 받은 파라미터의 @Valid 통해 발생한 Exception - public ResponseEntity>> handle(BindException ex) { - int statusCode = 400; - List exceptionResponses = new ArrayList<>(); - - if (ex instanceof ErrorResponse) { - statusCode = ((ErrorResponse) ex).getStatusCode().value(); - ex.getBindingResult().getFieldErrors().forEach(fieldError -> { - exceptionResponses.add(BindExceptionResponse.builder() - .message(fieldError.getDefaultMessage()) - .value(fieldError.getRejectedValue()) - .build()); - }); - } - - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), statusCode, exceptionResponses); - - CommonResponse> response = CommonResponse.createFailure(statusCode, "bindException", exceptionResponses); - - return ResponseEntity - .status(statusCode) - .body(response); - } - - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - // MethodArgumentTypeMismatchException == 클라이언트가 날짜 포맷을 다르게 입력한 경우 - public ResponseEntity> handle(MethodArgumentTypeMismatchException ex) { - int statusCode = 400; // 파라미터 값 실수이므로 4XX - - if (ex instanceof ErrorResponse) { // Exception이 ErrorResponse의 인스턴스라면 - statusCode = ((ErrorResponse) ex).getStatusCode().value(); // ErrorResponse에서 상태 값 가져오기 - } - - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), statusCode, ex.getMessage()); - - CommonResponse response = CommonResponse.createFailure(statusCode, INPUT_FORMAT_ERROR_MESSAGE); - - return ResponseEntity - .status(statusCode) - .body(response); - } - - @ExceptionHandler(Exception.class) // 모든 Exception 처리 - public ResponseEntity> handle(Exception ex) { - int statusCode = 500; - - if (ex instanceof ErrorResponse) { // Exception이 ErrorResponse의 인스턴스라면 (http status를 가지는 예외) - statusCode = ((ErrorResponse) ex).getStatusCode().value(); // ErrorResponse에서 상태 값 가져오기 - } - - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), statusCode, ex.getMessage()); - - CommonResponse response = CommonResponse.createFailure(statusCode, ex.getMessage()); - - return ResponseEntity - .status(statusCode) - .body(response); - } -} diff --git a/src/main/java/com/weeth/global/common/exception/ErrorCodeInterface.java b/src/main/java/com/weeth/global/common/exception/ErrorCodeInterface.java deleted file mode 100644 index de96249c..00000000 --- a/src/main/java/com/weeth/global/common/exception/ErrorCodeInterface.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.global.common.exception; - -import org.springframework.http.HttpStatus; - -import java.lang.reflect.Field; -import java.util.Objects; - -public interface ErrorCodeInterface { - int getCode(); - HttpStatus getStatus(); - String getMessage(); - - // ExplainError 어노테이션에 작성된 설명을 조회하는 메서드 - default String getExplainError() throws NoSuchFieldException { - Field field = this.getClass().getField(((Enum) this).name()); - ExplainError annotation = field.getAnnotation(ExplainError.class); - return Objects.nonNull(annotation) ? annotation.value() : getMessage(); - } -} diff --git a/src/main/java/com/weeth/global/common/exception/ExampleHolder.java b/src/main/java/com/weeth/global/common/exception/ExampleHolder.java deleted file mode 100644 index 897bf1cb..00000000 --- a/src/main/java/com/weeth/global/common/exception/ExampleHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.global.common.exception; - -import io.swagger.v3.oas.models.examples.Example; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class ExampleHolder { - private Example holder; - private String name; - private int code; -} diff --git a/src/main/java/com/weeth/global/common/exception/ExplainError.java b/src/main/java/com/weeth/global/common/exception/ExplainError.java deleted file mode 100644 index f609ee3b..00000000 --- a/src/main/java/com/weeth/global/common/exception/ExplainError.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.global.common.exception; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.FIELD) -@Retention(RetentionPolicy.RUNTIME) -public @interface ExplainError { - String value() default ""; -} diff --git a/src/main/java/com/weeth/global/config/AwsS3Config.java b/src/main/java/com/weeth/global/config/AwsS3Config.java deleted file mode 100644 index b53a82f4..00000000 --- a/src/main/java/com/weeth/global/config/AwsS3Config.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.weeth.global.config; - -import com.weeth.global.config.properties.AwsS3Properties; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; - -@Configuration -@RequiredArgsConstructor -public class AwsS3Config { - - private final AwsS3Properties awsS3Properties; - - @Bean - public S3Presigner s3Presigner() { - AwsBasicCredentials credentials = AwsBasicCredentials.create( - awsS3Properties.getCredentials().getAccessKey(), - awsS3Properties.getCredentials().getSecretKey() - ); - return S3Presigner.builder() - .region(Region.of(awsS3Properties.getRegion().getStatic())) - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .build(); - } -} diff --git a/src/main/java/com/weeth/global/config/RedisConfig.java b/src/main/java/com/weeth/global/config/RedisConfig.java deleted file mode 100644 index b7fe36ab..00000000 --- a/src/main/java/com/weeth/global/config/RedisConfig.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.weeth.global.config; - -import com.weeth.global.config.properties.RedisProperties; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.RedisStandaloneConfiguration; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.data.redis.core.RedisKeyValueAdapter; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; -import org.springframework.data.redis.serializer.StringRedisSerializer; - -@Configuration -@RequiredArgsConstructor -@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) // redis index ttl 설정 -public class RedisConfig { - - private final RedisProperties redisProperties; - - @Bean - public RedisConnectionFactory redisConnectionFactory() { - RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration(); - - redisConfiguration.setHostName(redisProperties.getHost()); - redisConfiguration.setPort(redisProperties.getPort()); - if (redisProperties.getPassword() != null && !redisProperties.getPassword().isEmpty()) { - redisConfiguration.setPassword(redisProperties.getPassword()); - } - - return new LettuceConnectionFactory(redisConfiguration); - } - - - @Bean - public RedisTemplate redisTemplate() { - RedisTemplate redisTemplate = new RedisTemplate<>(); - - redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new StringRedisSerializer()); - redisTemplate.setConnectionFactory(redisConnectionFactory()); - - return redisTemplate; - } - -} diff --git a/src/main/java/com/weeth/global/config/SecurityConfig.java b/src/main/java/com/weeth/global/config/SecurityConfig.java deleted file mode 100644 index e8175593..00000000 --- a/src/main/java/com/weeth/global/config/SecurityConfig.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.weeth.global.config; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.weeth.domain.user.domain.service.UserGetService; -import com.weeth.global.auth.authentication.CustomAccessDeniedHandler; -import com.weeth.global.auth.authentication.CustomAuthenticationEntryPoint; -import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase; -import com.weeth.global.auth.jwt.filter.JwtAuthenticationProcessingFilter; -import com.weeth.global.auth.jwt.service.JwtProvider; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.authorization.AuthorizationDecision; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.factory.PasswordEncoderFactories; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.Arrays; - -import static org.springframework.security.config.Customizer.withDefaults; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -@EnableMethodSecurity(prePostEnabled = true) -public class SecurityConfig { - - private final JwtProvider jwtProvider; - private final JwtService jwtService; - private final JwtManageUseCase jwtManageUseCase; - private final UserGetService userGetService; - private final ObjectMapper objectMapper; - - private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; - private final CustomAccessDeniedHandler customAccessDeniedHandler; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - return http - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .cors(withDefaults()) - .csrf(AbstractHttpConfigurer::disable) - .headers( - headersConfigurer -> - headersConfigurer - .frameOptions( - HeadersConfigurer.FrameOptionsConfig::sameOrigin - ) - ) - // 세션 사용하지 않으므로 STATELESS로 설정 - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - - //== URL별 권한 관리 옵션 ==// - .authorizeHttpRequests( - authorize -> - authorize - .requestMatchers("/api/v1/users/kakao/login", "api/v1/users/kakao/register", "api/v1/users/kakao/link", "/api/v1/users/apple/login", "/api/v1/users/apple/register", "/api/v1/users/apply", "/api/v1/users/email", "/api/v1/users/refresh").permitAll() - .requestMatchers("/health-check").permitAll() - .requestMatchers("/admin", "/admin/login", "/admin/account", "/admin/meeting", "/admin/member", "/admin/penalty").permitAll() - // 스웨거 경로 - .requestMatchers("/v3/api-docs", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/swagger/**").permitAll() - .requestMatchers("/actuator/prometheus") - .access((authentication, context) -> { - String ip = context.getRequest().getRemoteAddr(); - boolean allowed = ip.startsWith("172.") || ip.equals("127.0.0.1"); - return new AuthorizationDecision(allowed); - }) - .requestMatchers("/actuator/health").permitAll() - .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") - .anyRequest().authenticated() - ) - .exceptionHandling(exceptionHandling -> - exceptionHandling - .authenticationEntryPoint(customAuthenticationEntryPoint) - .accessDeniedHandler(customAccessDeniedHandler)) - .addFilterBefore(jwtAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class) - .build(); - } - - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - - configuration.setAllowedOriginPatterns(Arrays.asList("http://localhost:3000")); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("*")); - configuration.setExposedHeaders(Arrays.asList("Authorization", "Authorization_refresh")); - configuration.setAllowCredentials(true); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } - - @Bean - public PasswordEncoder passwordEncoder() { - return PasswordEncoderFactories.createDelegatingPasswordEncoder(); - } - - @Bean - public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() { - return new JwtAuthenticationProcessingFilter(jwtProvider, jwtService, userGetService); - } -} diff --git a/src/main/java/com/weeth/global/config/WebMvcConfig.java b/src/main/java/com/weeth/global/config/WebMvcConfig.java deleted file mode 100644 index 4d1fd0de..00000000 --- a/src/main/java/com/weeth/global/config/WebMvcConfig.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.global.config; - -import com.weeth.global.auth.jwt.service.JwtService; -import com.weeth.global.auth.resolver.CurrentUserArgumentResolver; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import java.util.List; - -@Configuration -@RequiredArgsConstructor -public class WebMvcConfig implements WebMvcConfigurer { - - private final JwtService jwtService; - - @Override - public void addArgumentResolvers(List resolvers) { - resolvers.add(new CurrentUserArgumentResolver(jwtService)); - } -} diff --git a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java deleted file mode 100644 index 971955f7..00000000 --- a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java +++ /dev/null @@ -1,194 +0,0 @@ -package com.weeth.global.config.swagger; - -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExampleHolder; -import com.weeth.global.common.response.CommonResponse; -import com.weeth.global.config.properties.JwtProperties; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.examples.Example; -import io.swagger.v3.oas.models.media.Content; -import io.swagger.v3.oas.models.media.MediaType; -import io.swagger.v3.oas.models.responses.ApiResponse; -import io.swagger.v3.oas.models.responses.ApiResponses; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import io.swagger.v3.oas.models.servers.Server; -import lombok.RequiredArgsConstructor; -import org.springdoc.core.models.GroupedOpenApi; -import org.springdoc.core.customizers.OperationCustomizer; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -import static java.util.stream.Collectors.groupingBy; - -@Configuration -@RequiredArgsConstructor -@OpenAPIDefinition( - info = @Info( - title = "Weeth API", - version = "v4.0.0", - description = """ - ## Response Code 규칙 - - Success: **1xxx** - - Domain Error: **2xxx** - - Server Error: **3xxx** - - Client Error: **4xxx** - - ## 도메인별 코드 범위 - | Domain | Success | Error | - |--------|---------|------| - | Account | 11xx | 21xx | - | Attendance | 12xx | 22xx | - | Board | 13xx | 23xx | - | Comment | 14xx | 24xx | - | File | 15xx | 25xx | - | Penalty | 16xx | 26xx | - | Schedule | 17xx | 27xx | - | User | 18xx | 28xx | - | Auth/JWT (Global) | - | 29xx | - - > 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요. - """ - ) -) -public class SwaggerConfig { - - private final JwtProperties jwtProperties; - - @Bean - public OpenAPI openAPI() { - SecurityScheme accessSecurityScheme = getAccessSecurityScheme(); - SecurityScheme refreshSecurityScheme = getRefreshSecurityScheme(); - - return new OpenAPI() - .addServersItem(new Server().url("/")) - .components(new Components() - .addSecuritySchemes("bearerAuth", accessSecurityScheme) - .addSecuritySchemes("refreshBearerAuth", refreshSecurityScheme)) - .security(List.of( - new SecurityRequirement().addList("bearerAuth"), - new SecurityRequirement().addList("refreshBearerAuth") - )); - } - - @Bean - public GroupedOpenApi adminApi() { - return GroupedOpenApi.builder() - .group("admin") - .pathsToMatch("/api/v1/admin/**") - .build(); - } - - @Bean - public GroupedOpenApi publicApi() { - return GroupedOpenApi.builder() - .group("public") - .pathsToMatch("/api/v1/**") - .pathsToExclude("/api/v1/admin/**") - .build(); - } - - @Bean - public OperationCustomizer operationCustomizer() { - return (operation, handlerMethod) -> { - ApiErrorCodeExample apiErrorCodeExample = findAnnotation(handlerMethod, ApiErrorCodeExample.class); - if (apiErrorCodeExample != null) { - for (Class type : apiErrorCodeExample.value()) { - generateErrorCodeResponseExample(operation.getResponses(), type); - } - } - - return operation; - }; - } - - private void generateErrorCodeResponseExample(ApiResponses responses, Class type) { - ErrorCodeInterface[] errorCodes = type.getEnumConstants(); - - Map> statusWithExampleHolders = - Arrays.stream(errorCodes) - .map(errorCode -> { - try { - String enumName = ((Enum) errorCode).name(); - - return ExampleHolder.builder() - .holder(getSwaggerExample(errorCode.getExplainError(), errorCode)) - .code(errorCode.getStatus().value()) - .name("[" + enumName + "] " + errorCode.getMessage()) - .build(); - } catch (NoSuchFieldException e) { - throw new RuntimeException(e); - } - }) - .collect(groupingBy(ExampleHolder::getCode)); - - addExamplesToResponses(responses, statusWithExampleHolders); - } - - private Example getSwaggerExample(String description, ErrorCodeInterface errorCode) { - CommonResponse errorResponse = CommonResponse.createFailure(errorCode.getCode(), errorCode.getMessage()); - Example example = new Example(); - example.description(description); - example.setValue(errorResponse); - - return example; - } - - private void addExamplesToResponses(ApiResponses responses, Map> statusWithExampleHolders) { - statusWithExampleHolders.forEach((status, exampleHolders) -> { - ApiResponse apiResponse = responses.computeIfAbsent(String.valueOf(status), k -> new ApiResponse()); - MediaType mediaType = getOrCreateMediaType(apiResponse); - exampleHolders.forEach(holder -> mediaType.addExamples(holder.getName(), holder.getHolder())); - }); - } - - private A findAnnotation(org.springframework.web.method.HandlerMethod handlerMethod, Class annotationType) { - A annotation = handlerMethod.getMethodAnnotation(annotationType); - if (annotation != null) { - return annotation; - } - return handlerMethod.getBeanType().getAnnotation(annotationType); - } - - private MediaType getOrCreateMediaType(ApiResponse apiResponse) { - Content content = apiResponse.getContent(); - if (content == null) { - content = new Content(); - apiResponse.setContent(content); - } - - MediaType mediaType = content.get("application/json"); - if (mediaType == null) { - mediaType = new MediaType(); - content.addMediaType("application/json", mediaType); - } - - return mediaType; - } - - private SecurityScheme getAccessSecurityScheme() { - return new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT") - .in(SecurityScheme.In.HEADER) - .name(jwtProperties.getAccess().getHeader()); - } - - private SecurityScheme getRefreshSecurityScheme() { - return new SecurityScheme() - .type(SecurityScheme.Type.APIKEY) - .scheme("bearer") - .bearerFormat("JWT") - .in(SecurityScheme.In.HEADER) - .name(jwtProperties.getRefresh().getHeader()); - } -} diff --git a/src/main/kotlin/com/weeth/WeethApplication.kt b/src/main/kotlin/com/weeth/WeethApplication.kt new file mode 100644 index 00000000..55d667ba --- /dev/null +++ b/src/main/kotlin/com/weeth/WeethApplication.kt @@ -0,0 +1,21 @@ +package com.weeth + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity + +@EnableAsync +@EnableScheduling +@EnableJpaAuditing +@EnableWebSecurity +@SpringBootApplication +@ConfigurationPropertiesScan +class WeethApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountSaveRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountSaveRequest.kt new file mode 100644 index 00000000..cc9be88c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountSaveRequest.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.account.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Positive + +data class AccountSaveRequest( + @field:Schema(description = "회비 설명", example = "2024년 2학기 회비") + @field:NotBlank + val description: String, + @field:Schema(description = "총 금액", example = "100000") + @field:NotNull + @field:Positive + val totalAmount: Int, + @field:Schema(description = "기수", example = "4") + @field:NotNull + val cardinal: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptSaveRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptSaveRequest.kt new file mode 100644 index 00000000..c177a3fa --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptSaveRequest.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.account.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Positive +import java.time.LocalDate + +data class ReceiptSaveRequest( + @field:Schema(description = "영수증 설명", example = "간식비") + val description: String?, + @field:Schema(description = "출처", example = "편의점") + val source: String?, + @field:Schema(description = "사용 금액", example = "10000") + @field:NotNull + @field:Positive + val amount: Int, + @field:Schema(description = "사용 날짜", example = "2024-09-01") + @field:NotNull + val date: LocalDate, + @field:Schema(description = "기수", example = "4") + @field:NotNull + val cardinal: Int, + @field:Valid + val files: List<@NotNull FileSaveRequest>?, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptUpdateRequest.kt new file mode 100644 index 00000000..cb146aff --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptUpdateRequest.kt @@ -0,0 +1,31 @@ +package com.weeth.domain.account.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Positive +import java.time.LocalDate + +data class ReceiptUpdateRequest( + @field:Schema(description = "영수증 설명", example = "간식비") + val description: String?, + @field:Schema(description = "출처", example = "편의점") + val source: String?, + @field:Schema(description = "사용 금액", example = "10000") + @field:NotNull + @field:Positive + val amount: Int, + @field:Schema(description = "사용 날짜", example = "2024-09-01") + @field:NotNull + val date: LocalDate, + @field:Schema(description = "기수", example = "4") + @field:NotNull + val cardinal: Int, + @field:Schema( + description = "첨부 파일 변경 규약: null=변경 안 함, []=전체 삭제, 배열 전달=해당 목록으로 교체", + nullable = true, + ) + @field:Valid + val files: List<@NotNull FileSaveRequest>?, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt new file mode 100644 index 00000000..3ac8b44d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.account.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class AccountResponse( + @field:Schema(description = "회비 ID", example = "1") + val accountId: Long, + @field:Schema(description = "회비 설명", example = "2024년 2학기 회비") + val description: String, + @field:Schema(description = "총 금액", example = "100000") + val totalAmount: Int, + @field:Schema(description = "현재 금액", example = "90000") + val currentAmount: Int, + @field:Schema(description = "최종 수정 시각") + val time: LocalDateTime?, + @field:Schema(description = "기수", example = "40") + val cardinal: Int, + @field:Schema(description = "영수증 목록") + val receipts: List, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/ReceiptResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/ReceiptResponse.kt new file mode 100644 index 00000000..df8d1d7b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/ReceiptResponse.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.account.application.dto.response + +import com.weeth.domain.file.application.dto.response.FileResponse +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class ReceiptResponse( + @field:Schema(description = "영수증 ID", example = "1") + val id: Long, + @field:Schema(description = "영수증 설명", example = "간식비") + val description: String?, + @field:Schema(description = "출처", example = "편의점") + val source: String?, + @field:Schema(description = "사용 금액", example = "10000") + val amount: Int, + @field:Schema(description = "사용 날짜", example = "2024-09-01") + val date: LocalDate, + @field:Schema(description = "첨부 파일 목록") + val fileUrls: List, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt new file mode 100644 index 00000000..2f48de19 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class AccountErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("요청한 회비 장부 ID가 존재하지 않을 때 발생합니다.") + ACCOUNT_NOT_FOUND(20100, HttpStatus.NOT_FOUND, "존재하지 않는 장부입니다."), + + @ExplainError("이미 존재하는 장부를 중복 생성하려고 할 때 발생합니다.") + ACCOUNT_EXISTS(20101, HttpStatus.BAD_REQUEST, "이미 생성된 장부입니다."), + + @ExplainError("요청한 영수증 내역이 존재하지 않을 때 발생합니다.") + RECEIPT_NOT_FOUND(20102, HttpStatus.NOT_FOUND, "존재하지 않는 내역입니다."), + + @ExplainError("영수증이 요청한 기수의 장부에 속하지 않거나 동아리에 속하지 않는 경우에 발생합니다.") + RECEIPT_ACCOUNT_MISMATCH(20103, HttpStatus.BAD_REQUEST, "영수증이 해당 기수의 장부에 속하지 않습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountExistsException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountExistsException.kt new file mode 100644 index 00000000..5886dead --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountExistsException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class AccountExistsException : BaseException(AccountErrorCode.ACCOUNT_EXISTS) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountNotFoundException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountNotFoundException.kt new file mode 100644 index 00000000..c7dc5a24 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class AccountNotFoundException : BaseException(AccountErrorCode.ACCOUNT_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptAccountMismatchException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptAccountMismatchException.kt new file mode 100644 index 00000000..04a34880 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptAccountMismatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class ReceiptAccountMismatchException : BaseException(AccountErrorCode.RECEIPT_ACCOUNT_MISMATCH) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptNotFoundException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptNotFoundException.kt new file mode 100644 index 00000000..db6f1b51 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class ReceiptNotFoundException : BaseException(AccountErrorCode.RECEIPT_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountMapper.kt b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountMapper.kt new file mode 100644 index 00000000..67fac93b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountMapper.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.account.application.mapper + +import com.weeth.domain.account.application.dto.response.AccountResponse +import com.weeth.domain.account.application.dto.response.ReceiptResponse +import com.weeth.domain.account.domain.entity.Account +import org.springframework.stereotype.Component + +@Component +class AccountMapper { + fun toResponse( + account: Account, + receipts: List, + ): AccountResponse = + AccountResponse( + accountId = account.id, + description = account.description, + totalAmount = account.totalAmount, + currentAmount = account.currentAmount, + time = account.modifiedAt, + cardinal = account.cardinal, + receipts = receipts, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/mapper/ReceiptMapper.kt b/src/main/kotlin/com/weeth/domain/account/application/mapper/ReceiptMapper.kt new file mode 100644 index 00000000..9999da3a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/mapper/ReceiptMapper.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.account.application.mapper + +import com.weeth.domain.account.application.dto.response.ReceiptResponse +import com.weeth.domain.account.domain.entity.Receipt +import com.weeth.domain.file.application.dto.response.FileResponse +import org.springframework.stereotype.Component + +@Component +class ReceiptMapper { + fun toResponse( + receipt: Receipt, + fileUrls: List, + ): ReceiptResponse = + ReceiptResponse( + id = receipt.id, + description = receipt.description, + source = receipt.source, + amount = receipt.amount, + date = receipt.date, + fileUrls = fileUrls, + ) + + fun toResponses( + receipts: List, + filesByReceiptId: Map>, + ): List = + receipts.map { receipt -> + toResponse(receipt, filesByReceiptId[receipt.id] ?: emptyList()) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt new file mode 100644 index 00000000..ea27267e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt @@ -0,0 +1,37 @@ +package com.weeth.domain.account.application.usecase.command + +import com.weeth.domain.account.application.dto.request.AccountSaveRequest +import com.weeth.domain.account.application.exception.AccountExistsException +import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageAccountUseCase( + private val accountRepository: AccountRepository, + private val cardinalReader: CardinalReader, + private val clubReader: ClubReader, + private val clubPermissionPolicy: ClubPermissionPolicy, +) { + @Transactional + fun save( + clubId: Long, + request: AccountSaveRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val club = clubReader.getClubById(clubId) + + if (accountRepository.existsByClubIdAndCardinal(clubId, request.cardinal)) throw AccountExistsException() + + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) + ?: throw CardinalNotFoundException() + + accountRepository.save(Account.create(club, request.description, request.totalAmount, request.cardinal)) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt new file mode 100644 index 00000000..1fe46b2d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt @@ -0,0 +1,96 @@ +package com.weeth.domain.account.application.usecase.command + +import com.weeth.domain.account.application.dto.request.ReceiptSaveRequest +import com.weeth.domain.account.application.dto.request.ReceiptUpdateRequest +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.exception.ReceiptAccountMismatchException +import com.weeth.domain.account.application.exception.ReceiptNotFoundException +import com.weeth.domain.account.domain.entity.Receipt +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.repository.ReceiptRepository +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageReceiptUseCase( + private val receiptRepository: ReceiptRepository, + private val accountRepository: AccountRepository, + private val fileReader: FileReader, + private val fileRepository: FileRepository, + private val cardinalReader: CardinalReader, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val fileMapper: FileMapper, +) { + @Transactional + fun save( + clubId: Long, + userId: Long, + request: ReceiptSaveRequest, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw AccountNotFoundException() + val account = + accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) ?: throw AccountNotFoundException() + + val receipt = + receiptRepository.save( + Receipt.create(request.description, request.source, request.amount, request.date, account), + ) + + account.spend(Money.of(request.amount)) + + fileRepository.saveAll(fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receipt.id)) + } + + @Transactional + fun update( + clubId: Long, + userId: Long, + receiptId: Long, + request: ReceiptUpdateRequest, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw AccountNotFoundException() + val account = + accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) ?: throw AccountNotFoundException() + val receipt = receiptRepository.findByIdOrNull(receiptId) ?: throw ReceiptNotFoundException() + + if (receipt.account.club.id != clubId || receipt.account.id != account.id) { + throw ReceiptAccountMismatchException() + } + + account.adjustSpend(Money.of(receipt.amount), Money.of(request.amount)) + + if (request.files != null) { + fileRepository.deleteAll(fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null)) + fileRepository.saveAll(fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receiptId)) + } + + receipt.update(request.description, request.source, request.amount, request.date) + } + + @Transactional + fun delete( + clubId: Long, + userId: Long, + receiptId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val receipt = receiptRepository.findByIdOrNull(receiptId) ?: throw ReceiptNotFoundException() + + if (receipt.account.club.id != clubId) throw ReceiptAccountMismatchException() + + receipt.account.cancelSpend(Money.of(receipt.amount)) + + fileRepository.deleteAll(fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null)) + receiptRepository.delete(receipt) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt new file mode 100644 index 00000000..53b5e57a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt @@ -0,0 +1,44 @@ +package com.weeth.domain.account.application.usecase.query + +import com.weeth.domain.account.application.dto.response.AccountResponse +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.mapper.AccountMapper +import com.weeth.domain.account.application.mapper.ReceiptMapper +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.repository.ReceiptRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetAccountQueryService( + private val accountRepository: AccountRepository, + private val receiptRepository: ReceiptRepository, + private val fileReader: FileReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val accountMapper: AccountMapper, + private val receiptMapper: ReceiptMapper, + private val fileMapper: FileMapper, +) { + fun findByCardinal( + clubId: Long, + userId: Long, + cardinal: Int, + ): AccountResponse { + clubMemberPolicy.getActiveMember(clubId, userId) + val account = accountRepository.findByClubIdAndCardinal(clubId, cardinal) ?: throw AccountNotFoundException() + val receipts = receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(account.id) + val receiptIds = receipts.map { it.id } + + val filesByReceiptId = + fileReader + .findAll(FileOwnerType.RECEIPT, receiptIds, null) + .groupBy({ it.ownerId }, { fileMapper.toFileResponse(it) }) + + return accountMapper.toResponse(account, receiptMapper.toResponses(receipts, filesByReceiptId)) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt b/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt new file mode 100644 index 00000000..c2643e83 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt @@ -0,0 +1,70 @@ +package com.weeth.domain.account.domain.entity + +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.club.domain.entity.Club +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne + +@Entity +class Account( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + val club: Club, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "account_id") + val id: Long = 0, + @Column(nullable = false) + val description: String, + @Column(nullable = false) + val totalAmount: Int, + @Column(nullable = false) + var currentAmount: Int, + @Column(nullable = false) + val cardinal: Int, +) : BaseEntity() { + fun spend(amount: Money) { + require(amount.value > 0) { "사용 금액은 0보다 커야 합니다: ${amount.value}" } + check(currentAmount >= amount.value) { "잔액이 부족합니다. 현재: $currentAmount, 요청: ${amount.value}" } + currentAmount -= amount.value + } + + fun cancelSpend(amount: Money) { + require(amount.value > 0) { "취소 금액은 0보다 커야 합니다: ${amount.value}" } + check(currentAmount + amount.value <= totalAmount) { "총액을 초과할 수 없습니다. 총액: $totalAmount" } + currentAmount += amount.value + } + + fun adjustSpend( + oldAmount: Money, + newAmount: Money, + ) { + cancelSpend(oldAmount) + spend(newAmount) + } + + companion object { + fun create( + club: Club, + description: String, + totalAmount: Int, + cardinal: Int, + ): Account { + require(totalAmount > 0) { "총액은 0보다 커야 합니다: $totalAmount" } + return Account( + club = club, + description = description, + totalAmount = totalAmount, + currentAmount = totalAmount, + cardinal = cardinal, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/domain/entity/Receipt.kt b/src/main/kotlin/com/weeth/domain/account/domain/entity/Receipt.kt new file mode 100644 index 00000000..b63786e1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/domain/entity/Receipt.kt @@ -0,0 +1,63 @@ +package com.weeth.domain.account.domain.entity + +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import java.time.LocalDate + +@Entity +class Receipt( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "receipt_id") + val id: Long = 0, + @Column + var description: String?, + @Column + var source: String?, + @Column(nullable = false) + var amount: Int, + @Column(nullable = false) + var date: LocalDate, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "account_id") + val account: Account, +) : BaseEntity() { + fun update( + description: String?, + source: String?, + amount: Int, + date: LocalDate, + ) { + require(amount > 0) { "금액은 0보다 커야 합니다: $amount" } + this.description = description + this.source = source + this.amount = amount + this.date = date + } + + companion object { + fun create( + description: String?, + source: String?, + amount: Int, + date: LocalDate, + account: Account, + ): Receipt { + require(amount > 0) { "금액은 0보다 커야 합니다: $amount" } + return Receipt( + description = description, + source = source, + amount = amount, + date = date, + account = account, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt new file mode 100644 index 00000000..21ccaaf3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.account.domain.repository + +import com.weeth.domain.account.domain.entity.Account +import org.springframework.data.jpa.repository.JpaRepository + +interface AccountRepository : JpaRepository { + fun findByClubIdAndCardinal( + clubId: Long, + cardinal: Int, + ): Account? + + fun existsByClubIdAndCardinal( + clubId: Long, + cardinal: Int, + ): Boolean +} diff --git a/src/main/kotlin/com/weeth/domain/account/domain/repository/ReceiptRepository.kt b/src/main/kotlin/com/weeth/domain/account/domain/repository/ReceiptRepository.kt new file mode 100644 index 00000000..4872fa45 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/domain/repository/ReceiptRepository.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.account.domain.repository + +import com.weeth.domain.account.domain.entity.Receipt +import org.springframework.data.jpa.repository.JpaRepository + +interface ReceiptRepository : JpaRepository { + fun findAllByAccountIdOrderByCreatedAtDesc(accountId: Long): List +} diff --git a/src/main/kotlin/com/weeth/domain/account/domain/vo/Money.kt b/src/main/kotlin/com/weeth/domain/account/domain/vo/Money.kt new file mode 100644 index 00000000..2e856076 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/domain/vo/Money.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.account.domain.vo + +@JvmInline +value class Money( + val value: Int, +) { + init { + require(value >= 0) { "금액은 0 이상이어야 합니다: $value" } + } + + operator fun plus(other: Money) = Money(value + other.value) + + operator fun minus(other: Money) = Money(value - other.value) + + companion object { + val ZERO = Money(0) + + fun of(value: Int) = Money(value) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt new file mode 100644 index 00000000..d44b4a7b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt @@ -0,0 +1,39 @@ +package com.weeth.domain.account.presentation + +import com.weeth.domain.account.application.dto.request.AccountSaveRequest +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.account.application.usecase.command.ManageAccountUseCase +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_SAVE_SUCCESS +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "ACCOUNT ADMIN", description = "[ADMIN] 회비 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/clubs/{clubId}/accounts") +@ApiErrorCodeExample(AccountErrorCode::class) +class AccountAdminController( + private val manageAccountUseCase: ManageAccountUseCase, +) { + @PostMapping + @Operation(summary = "회비 총 금액 기입", hidden = true) + fun save( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestBody @Valid dto: AccountSaveRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageAccountUseCase.save(clubId, dto, userId) + return CommonResponse.success(ACCOUNT_SAVE_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt new file mode 100644 index 00000000..36035ab5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt @@ -0,0 +1,36 @@ +package com.weeth.domain.account.presentation + +import com.weeth.domain.account.application.dto.response.AccountResponse +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.account.application.usecase.query.GetAccountQueryService +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_FIND_SUCCESS +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "ACCOUNT", description = "회비 API") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/accounts") +@ApiErrorCodeExample(AccountErrorCode::class) +class AccountController( + private val getAccountQueryService: GetAccountQueryService, +) { + @GetMapping("/{cardinal}") + @Operation(summary = "회비 내역 조회", hidden = true) + fun find( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @PathVariable cardinal: Int, + ): CommonResponse = + CommonResponse.success(ACCOUNT_FIND_SUCCESS, getAccountQueryService.findByCardinal(clubId, userId, cardinal)) +} diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt new file mode 100644 index 00000000..e93d5a60 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.account.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class AccountResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + ACCOUNT_SAVE_SUCCESS(10100, HttpStatus.OK, "회비가 성공적으로 저장되었습니다."), + ACCOUNT_FIND_SUCCESS(10101, HttpStatus.OK, "회비가 성공적으로 조회되었습니다."), + RECEIPT_SAVE_SUCCESS(10102, HttpStatus.OK, "영수증이 성공적으로 저장되었습니다."), + RECEIPT_DELETE_SUCCESS(10103, HttpStatus.OK, "영수증이 성공적으로 삭제되었습니다."), + RECEIPT_UPDATE_SUCCESS(10104, HttpStatus.OK, "영수증이 성공적으로 업데이트 되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt new file mode 100644 index 00000000..86cb2267 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt @@ -0,0 +1,70 @@ +package com.weeth.domain.account.presentation + +import com.weeth.domain.account.application.dto.request.ReceiptSaveRequest +import com.weeth.domain.account.application.dto.request.ReceiptUpdateRequest +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.account.application.usecase.command.ManageReceiptUseCase +import com.weeth.domain.account.presentation.AccountResponseCode.RECEIPT_DELETE_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.RECEIPT_SAVE_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.RECEIPT_UPDATE_SUCCESS +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "RECEIPT ADMIN", description = "[ADMIN] 회비 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/clubs/{clubId}/receipts") +@ApiErrorCodeExample(AccountErrorCode::class) +class ReceiptAdminController( + private val manageReceiptUseCase: ManageReceiptUseCase, +) { + @PostMapping + @Operation(summary = "회비 사용 내역 기입", hidden = true) + fun save( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @RequestBody @Valid dto: ReceiptSaveRequest, + ): CommonResponse { + manageReceiptUseCase.save(clubId, userId, dto) + return CommonResponse.success(RECEIPT_SAVE_SUCCESS) + } + + @DeleteMapping("/{receiptId}") + @Operation(summary = "회비 사용 내역 취소", hidden = true) + fun delete( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @PathVariable receiptId: Long, + ): CommonResponse { + manageReceiptUseCase.delete(clubId, userId, receiptId) + return CommonResponse.success(RECEIPT_DELETE_SUCCESS) + } + + @PatchMapping("/{receiptId}") + @Operation(summary = "회비 사용 내역 수정", hidden = true) + fun update( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @PathVariable receiptId: Long, + @RequestBody @Valid dto: ReceiptUpdateRequest, + ): CommonResponse { + manageReceiptUseCase.update(clubId, userId, receiptId, dto) + return CommonResponse.success(RECEIPT_UPDATE_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt new file mode 100644 index 00000000..d3c1c022 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.attendance.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class CheckInRequest( + @field:Schema(description = "출석 코드", example = "123456") + val code: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/UpdateAttendanceStatusRequest.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/UpdateAttendanceStatusRequest.kt new file mode 100644 index 00000000..326fac52 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/UpdateAttendanceStatusRequest.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.attendance.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Pattern + +data class UpdateAttendanceStatusRequest( + @field:Schema(description = "출석 ID", example = "1") + val attendanceId: Long, + @field:Schema(description = "변경할 출석 상태", example = "ATTEND") + @field:Pattern(regexp = "ATTEND|ABSENT") + val status: String, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceDetailResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceDetailResponse.kt new file mode 100644 index 00000000..419ce800 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceDetailResponse.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.attendance.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class AttendanceDetailResponse( + @field:Schema(description = "출석 횟수", example = "8") + val attendanceCount: Int, + @field:Schema(description = "전체 횟수", example = "10") + val total: Int, + @field:Schema(description = "결석 횟수", example = "2") + val absenceCount: Int, + @field:Schema(description = "출석 내역 목록") + val attendances: List, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt new file mode 100644 index 00000000..7574e8e7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.attendance.application.dto.response + +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import io.swagger.v3.oas.annotations.media.Schema + +data class AttendanceInfoResponse( + @field:Schema(description = "출석 ID", example = "1") + val id: Long, + @field:Schema(description = "출석 상태", example = "ATTEND") + val status: AttendanceStatus?, + @field:Schema(description = "사용자 이름", example = "이지훈") + val name: String?, + @field:Schema(description = "소속 학과", example = "컴퓨터공학과") + val department: String?, + @field:Schema(description = "학번", example = "20201234") + val studentId: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt new file mode 100644 index 00000000..625ddbde --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.attendance.application.dto.response + +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class AttendanceResponse( + @field:Schema(description = "출석 ID", example = "1") + val id: Long, + @field:Schema(description = "출석 상태", example = "ATTEND") + val status: AttendanceStatus?, + @field:Schema(description = "정기모임 제목", example = "1주차 정기모임") + val title: String?, + @field:Schema(description = "정기모임 시작 시간") + val start: LocalDateTime?, + @field:Schema(description = "정기모임 종료 시간") + val end: LocalDateTime?, + @field:Schema(description = "정기모임 장소", example = "공학관 401호") + val location: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt new file mode 100644 index 00000000..92e2d5d8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.attendance.application.dto.response + +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class AttendanceSummaryResponse( + @field:Schema(description = "출석률", example = "80") + val attendanceRate: Int?, + @field:Schema(description = "정기모임 제목", example = "1주차 정기모임") + val title: String?, + @field:Schema(description = "출석 상태", example = "ATTEND") + val status: AttendanceStatus?, + @field:Schema(description = "정기모임 id", example = "1") + val sessionId: Long?, + @field:Schema(description = "정기모임 시작 시간") + val start: LocalDateTime?, + @field:Schema(description = "정기모임 종료 시간") + val end: LocalDateTime?, + @field:Schema(description = "정기모임 장소", example = "공학관 401호") + val location: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/QrTokenResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/QrTokenResponse.kt new file mode 100644 index 00000000..45694ab5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/QrTokenResponse.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.attendance.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class QrTokenResponse( + @field:Schema(description = "세션 ID", example = "1") + val sessionId: Long, + @field:Schema(description = "6자리 출석 코드", example = "123456") + val code: Int, + @field:Schema(description = "QR 만료 시각", example = "2025-03-02T10:30:00") + val expiredAt: LocalDateTime, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceOpenEvent.kt b/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceOpenEvent.kt new file mode 100644 index 00000000..4736596c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceOpenEvent.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.attendance.application.event + +import java.time.LocalDateTime + +data class AttendanceOpenEvent( + val expiredAt: LocalDateTime, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceSseEvent.kt b/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceSseEvent.kt new file mode 100644 index 00000000..5aa6642c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceSseEvent.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.attendance.application.event + +object AttendanceSseEvent { + const val QR_OPEN = "qr-open" + const val QR_NONE = "qr-none" + const val QR_CLOSE = "qr-close" +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AlreadyAttendedException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AlreadyAttendedException.kt new file mode 100644 index 00000000..4fe6fa9c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AlreadyAttendedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class AlreadyAttendedException : BaseException(AttendanceErrorCode.ALREADY_ATTENDED) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceAlreadyClosedException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceAlreadyClosedException.kt new file mode 100644 index 00000000..a35d7515 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceAlreadyClosedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class AttendanceAlreadyClosedException : BaseException(AttendanceErrorCode.ATTENDANCE_ALREADY_CLOSED) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.kt new file mode 100644 index 00000000..a62d043b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class AttendanceCodeMismatchException : BaseException(AttendanceErrorCode.ATTENDANCE_CODE_MISMATCH) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt new file mode 100644 index 00000000..c56d3bd7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class AttendanceErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("출석 정보를 찾을 수 없을 때 발생합니다.") + ATTENDANCE_NOT_FOUND(20200, HttpStatus.NOT_FOUND, "출석 정보가 존재하지 않습니다."), + + @ExplainError("입력한 출석 코드가 생성된 코드와 일치하지 않을 때 발생합니다.") + ATTENDANCE_CODE_MISMATCH(20201, HttpStatus.BAD_REQUEST, "출석 코드가 일치하지 않습니다."), + + @ExplainError("사용자가 출석 일정을 직접 수정하려고 시도할 때 발생합니다. (출석 로직 위반)") + ATTENDANCE_EVENT_TYPE_NOT_MATCH(20202, HttpStatus.BAD_REQUEST, "출석일정은 직접 수정할 수 없습니다."), + + @ExplainError("QR 코드가 만료되었거나 어드민이 아직 QR을 생성하지 않았을 때 발생합니다.") + QR_TOKEN_EXPIRED(20203, HttpStatus.BAD_REQUEST, "QR 코드가 만료되었거나 존재하지 않습니다."), + + @ExplainError("해당 세션에 이미 출석 처리된 사용자가 다시 출석을 시도할 때 발생합니다.") + ALREADY_ATTENDED(20204, HttpStatus.CONFLICT, "이미 출석 처리된 세션입니다."), + + @ExplainError("출석이 자동 마감 처리된 후 체크인을 시도할 때 발생합니다.") + ATTENDANCE_ALREADY_CLOSED(20205, HttpStatus.CONFLICT, "이미 마감된 출석입니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.kt new file mode 100644 index 00000000..e8e2716a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class AttendanceEventTypeNotMatchException : BaseException(AttendanceErrorCode.ATTENDANCE_EVENT_TYPE_NOT_MATCH) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.kt new file mode 100644 index 00000000..95862a76 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class AttendanceNotFoundException : BaseException(AttendanceErrorCode.ATTENDANCE_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/QrTokenExpiredException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/QrTokenExpiredException.kt new file mode 100644 index 00000000..7aa762f5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/QrTokenExpiredException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class QrTokenExpiredException : BaseException(AttendanceErrorCode.QR_TOKEN_EXPIRED) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt new file mode 100644 index 00000000..4550ed03 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt @@ -0,0 +1,69 @@ +package com.weeth.domain.attendance.application.mapper + +import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse +import com.weeth.domain.attendance.application.dto.response.QrTokenResponse +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.session.domain.entity.Session +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class AttendanceMapper { + fun toSummaryResponse( + clubMember: ClubMember, + attendance: Attendance?, + ): AttendanceSummaryResponse = + AttendanceSummaryResponse( + attendanceRate = clubMember.attendanceStats.attendanceRate, + title = attendance?.session?.title, + status = attendance?.status, + sessionId = attendance?.session?.id, + start = attendance?.session?.start, + end = attendance?.session?.end, + location = attendance?.session?.location, + ) + + fun toDetailResponse( + clubMember: ClubMember, + attendances: List, + ): AttendanceDetailResponse = + AttendanceDetailResponse( + attendanceCount = clubMember.attendanceStats.attendanceCount, + total = clubMember.attendanceStats.attendanceCount + clubMember.attendanceStats.absenceCount, + absenceCount = clubMember.attendanceStats.absenceCount, + attendances = attendances, + ) + + fun toResponse(attendance: Attendance): AttendanceResponse = + AttendanceResponse( + id = attendance.id, + status = attendance.status, + title = attendance.session.title, + start = attendance.session.start, + end = attendance.session.end, + location = attendance.session.location, + ) + + fun toInfoResponse(attendance: Attendance): AttendanceInfoResponse = + AttendanceInfoResponse( + id = attendance.id, + status = attendance.status, + name = attendance.clubMember.user.name, + department = attendance.clubMember.user.department, + studentId = attendance.clubMember.user.studentId, + ) + + fun toQrTokenResponse( + session: Session, + expiredAt: LocalDateTime, + ): QrTokenResponse = + QrTokenResponse( + sessionId = session.id, + code = session.code, + expiredAt = expiredAt, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt new file mode 100644 index 00000000..4f642873 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt @@ -0,0 +1,45 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.dto.response.QrTokenResponse +import com.weeth.domain.attendance.application.event.AttendanceOpenEvent +import com.weeth.domain.attendance.application.event.AttendanceSseEvent +import com.weeth.domain.attendance.application.mapper.AttendanceMapper +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.port.SseBroadcastPort +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.session.domain.repository.SessionReader +import org.springframework.stereotype.Service +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime + +@Service +class GenerateQrTokenUseCase( + private val sessionReader: SessionReader, + private val qrAttendancePort: QrAttendancePort, + private val attendanceMapper: AttendanceMapper, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val ssePort: SseBroadcastPort, + transactionManager: PlatformTransactionManager, +) { + private val txTemplate = TransactionTemplate(transactionManager).apply { isReadOnly = true } + + fun execute( + sessionId: Long, + clubId: Long, + userId: Long, + ): QrTokenResponse { + val session = + requireNotNull( + txTemplate.execute { + clubPermissionPolicy.requireAdmin(clubId, userId) + sessionReader.getById(sessionId) + }, + ) + + qrAttendancePort.store(sessionId, session.code) + val expiredAt = LocalDateTime.now().plusSeconds(QrAttendancePort.TTL_SECONDS) + ssePort.broadcast(clubId, AttendanceSseEvent.QR_OPEN, AttendanceOpenEvent(expiredAt)) + return attendanceMapper.toQrTokenResponse(session, expiredAt) + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt new file mode 100644 index 00000000..1ca779b0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt @@ -0,0 +1,123 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest +import com.weeth.domain.attendance.application.exception.AlreadyAttendedException +import com.weeth.domain.attendance.application.exception.AttendanceAlreadyClosedException +import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.application.exception.QrTokenExpiredException +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.session.application.exception.SessionNotInProgressException +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.repository.SessionReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class ManageAttendanceUseCase( + private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val sessionReader: SessionReader, + private val attendanceRepository: AttendanceRepository, + private val qrAttendancePort: QrAttendancePort, +) { + @Transactional + fun checkIn( + clubId: Long, + userId: Long, + sessionId: Long, + code: Int, + ) { + val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) + + val session = sessionReader.getById(sessionId) + if (session.club.id != clubId) throw AttendanceNotFoundException() + if (!session.isCheckInAllowed(LocalDateTime.now())) throw SessionNotInProgressException() + + val storedCode = qrAttendancePort.getCode(sessionId) ?: throw QrTokenExpiredException() + if (storedCode != code) throw AttendanceCodeMismatchException() + + val lockedAttendance = + attendanceRepository.findBySessionAndClubMemberWithLock(session, clubMember) + ?: throw AttendanceNotFoundException() + + when (lockedAttendance.status) { + AttendanceStatus.ATTEND -> throw AlreadyAttendedException() + AttendanceStatus.ABSENT -> throw AttendanceAlreadyClosedException() + AttendanceStatus.PENDING -> Unit + } + + lockedAttendance.attend() + clubMember.attend() + } + + @Transactional + fun autoClose() { + val sessions = sessionReader.findAllByStatusAndEndBeforeOrderByEndAsc(SessionStatus.OPEN, LocalDateTime.now()) + if (sessions.isEmpty()) return + sessions.forEach { it.close() } + val pendingAttendances = + attendanceRepository.findPendingBySessionInAndMemberStatusWithLock( + sessions, + MemberStatus.ACTIVE, + AttendanceStatus.PENDING, + ) + closePendingAttendances(pendingAttendances) + } + + @Transactional + fun updateStatus( + clubId: Long, + userId: Long, + attendanceUpdates: List, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + if (attendanceUpdates.isEmpty()) return + // 데드락 방지: 일관된 순서로 락 획득 + val ids = attendanceUpdates.map { it.attendanceId }.sorted() + val attendanceMap = attendanceRepository.findAllByIdsWithLock(ids).associateBy { it.id } + // 데드락 방지: 처리 순서도 ID 오름차순으로 통일 + attendanceUpdates.sortedBy { it.attendanceId }.forEach { update -> + val attendance = attendanceMap[update.attendanceId] ?: throw AttendanceNotFoundException() + if (attendance.clubMember.club.id != clubId) throw AttendanceNotFoundException() + + val member = attendance.clubMember + val newStatus = AttendanceStatus.valueOf(update.status) + if (attendance.status == newStatus) return@forEach + + val prevStatus = attendance.status + attendance.adminOverride(newStatus) + + when (newStatus) { + AttendanceStatus.ABSENT -> { + if (prevStatus == AttendanceStatus.ATTEND) member.removeAttend() + member.absent() + } + + AttendanceStatus.ATTEND -> { + if (prevStatus == AttendanceStatus.ABSENT) member.removeAbsent() + member.attend() + } + + AttendanceStatus.PENDING -> { + if (prevStatus == AttendanceStatus.ATTEND) member.removeAttend() + if (prevStatus == AttendanceStatus.ABSENT) member.removeAbsent() + } + } + } + } + + private fun closePendingAttendances(attendances: List) { + attendances.forEach { attendance -> + attendance.close() + attendance.clubMember.absent() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCase.kt new file mode 100644 index 00000000..5ffb4e9d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCase.kt @@ -0,0 +1,42 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.event.AttendanceOpenEvent +import com.weeth.domain.attendance.application.event.AttendanceSseEvent +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.port.SseBroadcastPort +import com.weeth.domain.attendance.domain.port.SseSubscribePort +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.session.domain.repository.SessionReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +@Service +class SubscribeAttendanceSseUseCase( + private val sseSubscribePort: SseSubscribePort, + private val sseBroadcastPort: SseBroadcastPort, + private val clubMemberPolicy: ClubMemberPolicy, + private val sessionReader: SessionReader, + private val qrAttendancePort: QrAttendancePort, +) { + @Transactional(readOnly = true) + fun execute( + clubId: Long, + userId: Long, + ): SseEmitter { + clubMemberPolicy.getActiveMember(clubId, userId) + + val emitter = sseSubscribePort.subscribe(clubId, userId) + + val openSession = sessionReader.findOpenByClubId(clubId) + val expiredAt = openSession?.let { qrAttendancePort.getExpiredAt(it.id) } + + if (expiredAt != null) { + sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_OPEN, AttendanceOpenEvent(expiredAt)) + } else { + sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_NONE, null) + } + + return emitter + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt new file mode 100644 index 00000000..1bb00427 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -0,0 +1,86 @@ +package com.weeth.domain.attendance.application.usecase.query + +import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.application.mapper.AttendanceMapper +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.session.domain.repository.SessionReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class GetAttendanceQueryService( + private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, + private val sessionReader: SessionReader, + private val attendanceRepository: AttendanceRepository, + private val attendanceMapper: AttendanceMapper, +) { + fun findAttendance( + clubId: Long, + userId: Long, + ): AttendanceSummaryResponse { + val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) + val now = LocalDateTime.now() + val today = now.toLocalDate() + val todayAttendances = + attendanceRepository.findTodayByClubMemberId( + clubMember.id, + today.atStartOfDay(), + today.plusDays(1).atStartOfDay(), + ) + + val todayAttendance = + when { + todayAttendances.size <= 1 -> { + todayAttendances.firstOrNull() + } + + else -> { + todayAttendances.firstOrNull { it.session.start >= now } + ?: todayAttendances.last() + } + } + + return attendanceMapper.toSummaryResponse(clubMember, todayAttendance) + } + + fun findAllDetailsByCurrentCardinal( + clubId: Long, + userId: Long, + ): AttendanceDetailResponse { + val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) + val currentCardinal = clubMemberCardinalPolicy.getCurrentCardinal(clubMember) + val responses = + attendanceRepository + .findAllByClubMemberIdAndCardinal(clubMember.id, currentCardinal.cardinalNumber) + .map(attendanceMapper::toResponse) + + return attendanceMapper.toDetailResponse(clubMember, responses) + } + + fun findAllAttendanceBySession( + clubId: Long, + userId: Long, + sessionId: Long, + ): List { + clubPermissionPolicy.requireAdmin(clubId, userId) + val session = sessionReader.getById(sessionId) + + if (session.club.id != clubId) { + throw AttendanceNotFoundException() + } + + val attendances = attendanceRepository.findAllBySessionAndClubMemberMemberStatus(session, MemberStatus.ACTIVE) + return attendances.map(attendanceMapper::toInfoResponse) + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt new file mode 100644 index 00000000..6153b009 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt @@ -0,0 +1,78 @@ +package com.weeth.domain.attendance.domain.entity + +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.session.domain.entity.Session +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import org.hibernate.annotations.OnDelete +import org.hibernate.annotations.OnDeleteAction + +@Entity +@Table( + uniqueConstraints = [ + UniqueConstraint(name = "uk_attendance_session_member", columnNames = ["session_id", "club_member_id"]), + ], +) +class Attendance( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id") + val session: Session, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_member_id") + @OnDelete(action = OnDeleteAction.CASCADE) + val clubMember: ClubMember, + status: AttendanceStatus = AttendanceStatus.PENDING, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "attendance_id") + var id: Long = 0L + private set + + @Enumerated(EnumType.STRING) + var status: AttendanceStatus = status + private set + + fun attend() { + check(status == AttendanceStatus.PENDING) { "이미 처리된 출석입니다" } + status = AttendanceStatus.ATTEND + } + + fun absent() { + check(status == AttendanceStatus.PENDING) { "이미 처리된 출석입니다" } + status = AttendanceStatus.ABSENT + } + + // 기존 close() 는 absent() 로 대체 (AttendanceUpdateService 호환 유지) + fun close() = absent() + + fun adminOverride(newStatus: AttendanceStatus) { + status = newStatus + } + + fun isPending(): Boolean = status == AttendanceStatus.PENDING + + fun isWrong(code: Int): Boolean = !session.isCodeMatch(code) + + companion object { + fun create( + session: Session, + clubMember: ClubMember, + ): Attendance { + require(session.club.id == clubMember.club.id) { "세션과 멤버의 동아리가 일치하지 않습니다" } + return Attendance(session = session, clubMember = clubMember) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/enums/AttendanceStatus.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/enums/AttendanceStatus.kt new file mode 100644 index 00000000..9bdf0f1a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/enums/AttendanceStatus.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.attendance.domain.enums + +enum class AttendanceStatus { + ATTEND, + PENDING, + ABSENT, +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/port/QrAttendancePort.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/port/QrAttendancePort.kt new file mode 100644 index 00000000..8d4d9f4a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/port/QrAttendancePort.kt @@ -0,0 +1,31 @@ +package com.weeth.domain.attendance.domain.port + +import java.time.LocalDateTime + +interface QrAttendancePort { + companion object { + const val TTL_SECONDS = 600L + const val KEY_PREFIX = "qr:" + } + + /** + * QR 출석 코드를 Redis에 저장합니다. + * key: sessionId, value: code (TTL 10분) + */ + fun store( + sessionId: Long, + code: Int, + ) + + /** + * sessionId에 해당하는 활성화된 QR 코드를 반환합니다. + * QR이 생성된 적 없거나 TTL이 만료된 경우 null을 반환합니다. + */ + fun getCode(sessionId: Long): Int? + + /** + * sessionId에 해당하는 QR 코드의 만료 시각을 반환합니다. + * QR이 없거나 TTL이 만료된 경우 null을 반환합니다. + */ + fun getExpiredAt(sessionId: Long): LocalDateTime? +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseBroadcastPort.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseBroadcastPort.kt new file mode 100644 index 00000000..39cdae25 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseBroadcastPort.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.attendance.domain.port + +interface SseBroadcastPort { + fun broadcast( + clubId: Long, + eventName: String, + data: Any?, + ) + + fun sendToUser( + clubId: Long, + userId: Long, + eventName: String, + data: Any?, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseSubscribePort.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseSubscribePort.kt new file mode 100644 index 00000000..92b83d5a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseSubscribePort.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.attendance.domain.port + +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +interface SseSubscribePort { + fun subscribe( + clubId: Long, + userId: Long, + ): SseEmitter +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt new file mode 100644 index 00000000..8ca23fec --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -0,0 +1,149 @@ +package com.weeth.domain.attendance.domain.repository + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.session.domain.entity.Session +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface AttendanceRepository : JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session = :session AND a.clubMember = :clubMember", + ) + fun findBySessionAndClubMemberWithLock( + @Param("session") session: Session, + @Param("clubMember") clubMember: ClubMember, + ): Attendance? + + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) + fun findAllBySessionAndClubMemberMemberStatus( + session: Session, + memberStatus: MemberStatus, + ): List + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session = :session AND cm.memberStatus = :status ORDER BY a.id ASC", + ) + fun findAllBySessionAndClubMemberMemberStatusWithLock( + @Param("session") session: Session, + @Param("status") status: MemberStatus, + ): List + + // 교착 방지: id 오름차순 정렬로 일관된 락 획득 순서 보장 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user JOIN FETCH cm.club WHERE a.id IN :ids ORDER BY a.id ASC", + ) + fun findAllByIdsWithLock( + @Param("ids") ids: List, + ): List + + @Query( + """ + SELECT a FROM Attendance a + JOIN FETCH a.session s + WHERE a.clubMember.id = :clubMemberId + AND s.start <= :checkInEnd + AND s.end > :now + """, + ) + fun findCurrentByClubMemberId( + @Param("clubMemberId") clubMemberId: Long, + @Param("now") now: LocalDateTime, + @Param("checkInEnd") checkInEnd: LocalDateTime, + ): Attendance? + + @Query( + """ + SELECT a FROM Attendance a + JOIN FETCH a.session s + WHERE a.clubMember.id = :clubMemberId + AND s.start >= :dayStart + AND s.end < :dayEnd + ORDER BY s.start ASC + """, + ) + fun findTodayByClubMemberId( + @Param("clubMemberId") clubMemberId: Long, + @Param("dayStart") dayStart: LocalDateTime, + @Param("dayEnd") dayEnd: LocalDateTime, + ): List + + @Query( + """ + SELECT a FROM Attendance a + JOIN FETCH a.session s + WHERE a.clubMember.id = :clubMemberId + AND s.cardinal = :cardinal + ORDER BY s.start + """, + ) + fun findAllByClubMemberIdAndCardinal( + @Param("clubMemberId") clubMemberId: Long, + @Param("cardinal") cardinal: Int, + ): List + + @Query("SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session IN :sessions") + fun findAllBySessionIn( + @Param("sessions") sessions: List, + ): List + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session IN :sessions AND cm.memberStatus = :status ORDER BY a.id ASC", + ) + fun findAllBySessionInAndClubMemberMemberStatusWithLock( + @Param("sessions") sessions: List, + @Param("status") status: MemberStatus, + ): List + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session IN :sessions AND cm.memberStatus = :memberStatus AND a.status = :attendanceStatus ORDER BY a.id ASC", + ) + fun findPendingBySessionInAndMemberStatusWithLock( + @Param("sessions") sessions: List, + @Param("memberStatus") memberStatus: MemberStatus, + @Param("attendanceStatus") attendanceStatus: AttendanceStatus, + ): List + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("DELETE FROM Attendance a WHERE a.session = :session") + fun deleteAllBySession(session: Session) + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("DELETE FROM Attendance a WHERE a.session IN :sessions") + fun deleteAllBySessionIn( + @Param("sessions") sessions: List, + ) + + @Query("SELECT a FROM Attendance a JOIN a.session s WHERE a.clubMember = :clubMember AND s.club.id = :clubId") + fun findAllByClubMemberAndClubId( + @Param("clubMember") clubMember: ClubMember, + @Param("clubId") clubId: Long, + ): List + + // NOTE: session, clubMember는 lazy 로딩 — attendance.status 접근 전용. 연관 필드 접근 시 JOIN FETCH 추가 필요 + @Query("SELECT a FROM Attendance a WHERE a.clubMember = :clubMember AND a.session IN :sessions") + fun findAllByClubMemberAndSessionIn( + @Param("clubMember") clubMember: ClubMember, + @Param("sessions") sessions: List, + ): List +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt new file mode 100644 index 00000000..44c3a308 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.attendance.infrastructure + +import com.weeth.domain.attendance.application.usecase.command.ManageAttendanceUseCase +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class AttendanceScheduler( + private val manageAttendanceUseCase: ManageAttendanceUseCase, +) { + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + fun autoCloseAttendance() { + manageAttendanceUseCase.autoClose() + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListener.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListener.kt new file mode 100644 index 00000000..c47b99cc --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListener.kt @@ -0,0 +1,35 @@ +package com.weeth.domain.attendance.infrastructure + +import com.weeth.domain.attendance.application.event.AttendanceSseEvent +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.port.SseBroadcastPort +import com.weeth.domain.session.domain.repository.SessionReader +import org.slf4j.LoggerFactory +import org.springframework.data.redis.connection.Message +import org.springframework.data.redis.connection.MessageListener +import org.springframework.stereotype.Component + +@Component +class QrExpiredEventListener( + private val sessionReader: SessionReader, + private val sseBroadcastPort: SseBroadcastPort, +) : MessageListener { + private val log = LoggerFactory.getLogger(javaClass) + + override fun onMessage( + message: Message, + pattern: ByteArray?, + ) { + val key = message.body.decodeToString() + if (!key.startsWith(QrAttendancePort.KEY_PREFIX)) return + + val sessionId = key.removePrefix(QrAttendancePort.KEY_PREFIX).toLongOrNull() ?: return + + val clubId = sessionReader.findClubIdById(sessionId) ?: return + runCatching { + sseBroadcastPort.broadcast(clubId, AttendanceSseEvent.QR_CLOSE, null) + }.onFailure { e -> + log.error("QR 만료 이벤트 처리 실패: sessionId={}", sessionId, e) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/RedisQrAttendanceAdapter.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/RedisQrAttendanceAdapter.kt new file mode 100644 index 00000000..0f1c8346 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/RedisQrAttendanceAdapter.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.attendance.infrastructure + +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit + +@Component +class RedisQrAttendanceAdapter( + private val redisTemplate: RedisTemplate, +) : QrAttendancePort { + override fun store( + sessionId: Long, + code: Int, + ) { + redisTemplate.opsForValue().set(key(sessionId), code.toString(), QrAttendancePort.TTL_SECONDS, TimeUnit.SECONDS) + } + + override fun getCode(sessionId: Long): Int? = redisTemplate.opsForValue().get(key(sessionId))?.toIntOrNull() + + override fun getExpiredAt(sessionId: Long): LocalDateTime? { + val ttl = redisTemplate.getExpire(key(sessionId), TimeUnit.SECONDS) + return if (ttl > 0) LocalDateTime.now().plusSeconds(ttl) else null + } + + private fun key(sessionId: Long) = "${QrAttendancePort.KEY_PREFIX}$sessionId" +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseAttendanceAdapter.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseAttendanceAdapter.kt new file mode 100644 index 00000000..aeeba944 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseAttendanceAdapter.kt @@ -0,0 +1,73 @@ +package com.weeth.domain.attendance.infrastructure + +import com.fasterxml.jackson.databind.ObjectMapper +import com.weeth.domain.attendance.domain.port.SseBroadcastPort +import com.weeth.domain.attendance.domain.port.SseSubscribePort +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +@Component +class SseAttendanceAdapter( + private val store: SseEmitterStore, + private val objectMapper: ObjectMapper, +) : SseBroadcastPort, + SseSubscribePort { + companion object { + private val log = LoggerFactory.getLogger(SseAttendanceAdapter::class.java) + private const val TIMEOUT = 30 * 60 * 1000L + private const val EVENT_CONNECT = "connect" + } + + override fun subscribe( + clubId: Long, + userId: Long, + ): SseEmitter { + val emitter = SseEmitter(TIMEOUT) + val cleanup = { store.remove(clubId, userId, emitter) } + + store.replace(clubId, userId, emitter) + emitter.onCompletion(cleanup) + emitter.onTimeout(cleanup) + emitter.onError { cleanup() } + + runCatching { + emitter.send(SseEmitter.event().name(EVENT_CONNECT).data("connected")) + }.onFailure { cleanup() } + + return emitter + } + + override fun broadcast( + clubId: Long, + eventName: String, + data: Any?, + ) { + val payload = + runCatching { objectMapper.writeValueAsString(data) } + .onFailure { log.error("SSE payload 직렬화 실패: eventName={}", eventName, it) } + .getOrElse { return } + + store.getAllByClub(clubId).forEach { (userId, emitter) -> + runCatching { + emitter.send(SseEmitter.event().name(eventName).data(payload)) + }.onFailure { store.remove(clubId, userId, emitter) } + } + } + + override fun sendToUser( + clubId: Long, + userId: Long, + eventName: String, + data: Any?, + ) { + val emitter = store.getByUser(clubId, userId) ?: return + val payload = + runCatching { objectMapper.writeValueAsString(data) } + .onFailure { log.error("SSE payload 직렬화 실패: eventName={}", eventName, it) } + .getOrElse { return } + runCatching { + emitter.send(SseEmitter.event().name(eventName).data(payload)) + }.onFailure { store.remove(clubId, userId, emitter) } + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStore.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStore.kt new file mode 100644 index 00000000..092595e0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStore.kt @@ -0,0 +1,44 @@ +package com.weeth.domain.attendance.infrastructure + +import org.springframework.stereotype.Component +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference + +@Component +class SseEmitterStore { + private val store = ConcurrentHashMap>() + + fun replace( + clubId: Long, + userId: Long, + emitter: SseEmitter, + ) { + val oldRef = AtomicReference() + store.compute(clubId) { _, userMap -> + (userMap ?: ConcurrentHashMap()).apply { oldRef.set(put(userId, emitter)) } + } + oldRef.get()?.complete() + } + + fun remove( + clubId: Long, + userId: Long, + emitter: SseEmitter, + ) { + store.computeIfPresent(clubId) { _, userMap -> + userMap.remove(userId, emitter) + userMap.takeUnless { it.isEmpty() } + } + } + + fun getByUser( + clubId: Long, + userId: Long, + ): SseEmitter? = store[clubId]?.get(userId) + + fun getAllByClub(clubId: Long): List> = + store[clubId] + ?.map { (userId, emitter) -> userId to emitter } + ?: emptyList() +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt new file mode 100644 index 00000000..15a35c3e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt @@ -0,0 +1,74 @@ +package com.weeth.domain.attendance.presentation + +import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest +import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse +import com.weeth.domain.attendance.application.dto.response.QrTokenResponse +import com.weeth.domain.attendance.application.exception.AttendanceErrorCode +import com.weeth.domain.attendance.application.usecase.command.GenerateQrTokenUseCase +import com.weeth.domain.attendance.application.usecase.command.ManageAttendanceUseCase +import com.weeth.domain.attendance.application.usecase.query.GetAttendanceQueryService +import com.weeth.domain.session.application.exception.SessionErrorCode +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "ATTENDANCE ADMIN", description = "[ADMIN] 출석 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/clubs/{clubId}/attendances") +@ApiErrorCodeExample(AttendanceErrorCode::class, SessionErrorCode::class) +class AttendanceAdminController( + private val manageAttendanceUseCase: ManageAttendanceUseCase, + private val getAttendanceQueryService: GetAttendanceQueryService, + private val generateQrTokenUseCase: GenerateQrTokenUseCase, +) { + @GetMapping("/{sessionId}") + @Operation(summary = "모든 인원 정기모임 출석 정보 조회") + fun getAllAttendance( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @PathVariable sessionId: Long, + ): CommonResponse> = + CommonResponse.success( + AttendanceResponseCode.ATTENDANCE_FIND_DETAIL_SUCCESS, + getAttendanceQueryService.findAllAttendanceBySession(clubId, userId, sessionId), + ) + + @PatchMapping("/status") + @Operation(summary = "모든 인원 정기모임 개별 출석 상태 수정") + fun updateAttendanceStatus( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @RequestBody @Valid attendanceUpdates: List, + ): CommonResponse { + manageAttendanceUseCase.updateStatus(clubId, userId, attendanceUpdates) + return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_UPDATED_SUCCESS) + } + + @PostMapping("/{sessionId}/qr") + @Operation(summary = "QR 코드 생성") + fun generateQr( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable sessionId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + AttendanceResponseCode.QR_TOKEN_GENERATE_SUCCESS, + generateQrTokenUseCase.execute(sessionId, clubId, userId), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt new file mode 100644 index 00000000..cf1f7986 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -0,0 +1,86 @@ +package com.weeth.domain.attendance.presentation + +import com.weeth.domain.attendance.application.dto.request.CheckInRequest +import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse +import com.weeth.domain.attendance.application.exception.AttendanceErrorCode +import com.weeth.domain.attendance.application.usecase.command.ManageAttendanceUseCase +import com.weeth.domain.attendance.application.usecase.command.SubscribeAttendanceSseUseCase +import com.weeth.domain.attendance.application.usecase.query.GetAttendanceQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +@Tag(name = "ATTENDANCE", description = "출석 API") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/attendances") +@ApiErrorCodeExample(AttendanceErrorCode::class) +class AttendanceController( + private val manageAttendanceUseCase: ManageAttendanceUseCase, + private val getAttendanceQueryService: GetAttendanceQueryService, + private val subscribeAttendanceSseUseCase: SubscribeAttendanceSseUseCase, +) { + @PostMapping("/sessions/{sessionId}/check-in") + @Operation(summary = "출석체크") + fun checkIn( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable sessionId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @RequestBody checkIn: CheckInRequest, + ): CommonResponse { + manageAttendanceUseCase.checkIn(clubId, userId, sessionId, checkIn.code) + return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_CHECKIN_SUCCESS) + } + + @GetMapping + @Operation( + summary = "내 출석 요약 조회", + description = """ + 출석을 진행하기 전 오늘의 출석 유무를 확인하기 위해서 사용됩니다.(대시보드, 출석 페이지). + 출석률은 상시 표시되며, 오늘의 출석이 없는 경우 status를 포함한 필드는 null로 반환됩니다. + """, + ) + fun find( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + AttendanceResponseCode.ATTENDANCE_FIND_SUCCESS, + getAttendanceQueryService.findAttendance(clubId, userId), + ) + + @GetMapping("/detail") + @Operation(summary = "내 출석 상세 내역 조회") + fun findAll( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + AttendanceResponseCode.ATTENDANCE_FIND_ALL_SUCCESS, + getAttendanceQueryService.findAllDetailsByCurrentCardinal(clubId, userId), + ) + + @GetMapping("/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) + @Operation(summary = "출석 SSE 구독") + fun subscribe( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): SseEmitter = subscribeAttendanceSseUseCase.execute(clubId, userId) +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt new file mode 100644 index 00000000..f3d1932b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.attendance.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class AttendanceResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + // AttendanceAdminController 관련 + ATTENDANCE_UPDATED_SUCCESS(10200, HttpStatus.OK, "개별 출석 상태가 성공적으로 수정되었습니다."), + ATTENDANCE_FIND_DETAIL_SUCCESS(10201, HttpStatus.OK, "모든 인원의 정기모임 출석 정보가 성공적으로 조회되었습니다."), + + // AttendanceController 관련 + ATTENDANCE_CHECKIN_SUCCESS(10202, HttpStatus.OK, "출석이 성공적으로 처리되었습니다."), + ATTENDANCE_FIND_SUCCESS(10203, HttpStatus.OK, "사용자의 출석 정보가 성공적으로 조회되었습니다."), + ATTENDANCE_FIND_ALL_SUCCESS(10204, HttpStatus.OK, "사용자의 상세 출석 정보가 성공적으로 조회되었습니다."), + + // QR 관련 + QR_TOKEN_GENERATE_SUCCESS(10205, HttpStatus.OK, "QR 코드가 성공적으로 생성되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt new file mode 100644 index 00000000..06613d62 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.club.domain.enums.MemberRole +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class CreateBoardRequest( + @field:Schema(description = "게시판 이름", example = "공지사항") + @field:NotBlank + @field:Size(max = 20) + val name: String, + @field:Schema(description = "게시판 설명", example = "공지사항 게시판입니다.") + @field:NotBlank + @field:Size(max = 30) + val description: String, + @field:Schema(description = "게시판 타입", example = "NOTICE") + @field:NotNull + var type: BoardType, + @field:Schema(description = "댓글 허용 여부", example = "true") + val commentEnabled: Boolean = true, + @field:Schema(description = "게시글 작성 권한", example = "USER") + val writePermission: MemberRole = MemberRole.USER, + @field:Schema(description = "비공개 게시판 여부", example = "false") + val isPrivate: Boolean = false, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt new file mode 100644 index 00000000..296d8ffd --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class CreatePostRequest( + @field:Schema(description = "게시글 제목", example = "스터디 로그") + @field:NotBlank + @field:Size(max = 200) + val title: String, + @field:Schema(description = "게시글 내용", example = "내용입니다.") + @field:NotBlank + val content: String, + @field:Schema(description = "첨부 파일 목록", nullable = true) + @field:Valid + val files: List<@NotNull FileSaveRequest>? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/ReorderBoardsRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/ReorderBoardsRequest.kt new file mode 100644 index 00000000..61cc978f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/ReorderBoardsRequest.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.board.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty + +data class ReorderBoardsRequest( + @field:Schema( + description = + "표시할 순서대로 게시판 ID를 담아 보내주세요. " + + "공지사항과 전체 게시판은 고정이므로 제외하고 나머지 게시판 ID만 포함해야 합니다.", + example = "[3, 1, 2]", + ) + @field:NotEmpty + val boardIds: List, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt new file mode 100644 index 00000000..199897c6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.club.domain.enums.MemberRole +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size + +data class UpdateBoardRequest( + @field:Schema(description = "게시판 이름", example = "새 공지사항", nullable = true) + @field:Size(max = 20) + val name: String? = null, + @field:Schema(description = "게시판 설명", example = "운영 관련 새 공지사항입니다.", nullable = true) + @field:Size(max = 30) + val description: String? = null, + @field:Schema(description = "댓글 허용 여부", example = "true", nullable = true) + val commentEnabled: Boolean? = null, + @field:Schema(description = "게시글 작성 권한", example = "USER", nullable = true) + val writePermission: MemberRole? = null, + @field:Schema(description = "비공개 게시판 여부", example = "false", nullable = true) + val isPrivate: Boolean? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt new file mode 100644 index 00000000..b0207aeb --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class UpdatePostRequest( + @field:Schema(description = "게시글 제목 (null=변경 안 함)") + @field:Size(max = 200) + val title: String? = null, + @field:Schema(description = "게시글 내용 (null=변경 안 함)") + val content: String? = null, + @field:Schema(description = "첨부 파일 변경 규약: null=변경 안 함, []=전체 삭제, 배열 전달=해당 목록으로 교체", nullable = true) + @field:Valid + val files: List<@NotNull FileSaveRequest>? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardConfigResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardConfigResponse.kt new file mode 100644 index 00000000..83078405 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardConfigResponse.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.board.application.dto.response + +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.club.domain.enums.MemberRole +import io.swagger.v3.oas.annotations.media.Schema + +data class BoardConfigResponse( + @field:Schema(description = "글 작성 가능 여부") + val canWrite: Boolean, + @field:Schema(description = "댓글 작성 가능 여부") + val canComment: Boolean, +) { + companion object { + fun of( + board: Board, + memberRole: MemberRole, + ) = BoardConfigResponse( + canWrite = board.canWriteBy(memberRole), + canComment = board.isCommentEnabled, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt new file mode 100644 index 00000000..c64065f3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.board.application.dto.response + +import com.fasterxml.jackson.annotation.JsonInclude +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.club.domain.enums.MemberRole +import io.swagger.v3.oas.annotations.media.Schema + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class BoardDetailResponse( + @field:Schema(description = "게시판 ID (전체 게시판은 null)") + val id: Long?, + @field:Schema(description = "게시판 이름") + val name: String, + @field:Schema(description = "게시판 설명 (관리자만 조회 가능)") + val description: String?, + @field:Schema(description = "게시판 타입") + val type: BoardType, + @field:Schema(description = "댓글 허용 여부 (전체 게시판은 null)") + val commentEnabled: Boolean?, + @field:Schema(description = "게시글 작성 권한 (전체 게시판은 null)") + val writePermission: MemberRole?, + @field:Schema(description = "비공개 게시판 여부 (전체 게시판은 null)") + val isPrivate: Boolean?, + @field:Schema(description = "표시 순서 (전체 게시판은 null)") + val displayOrder: Int?, + @field:Schema(description = "게시글 수 (관리자 페이지에서만 값 존재)") + val postCount: Int? = null, + @field:Schema(description = "삭제 여부 (관리자 페이지에서만 값 존재)") + val isDeleted: Boolean?, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt new file mode 100644 index 00000000..909c7935 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.board.application.dto.response + +import com.weeth.domain.board.domain.enums.BoardType +import io.swagger.v3.oas.annotations.media.Schema + +data class BoardListResponse( + @field:Schema(description = "게시판 ID (전체 게시판은 null)") + val id: Long?, + @field:Schema(description = "게시판 이름") + val name: String, + @field:Schema(description = "게시판 타입") + val type: BoardType, + @field:Schema(description = "게시판 설정") + val boardConfig: BoardConfigResponse, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardNameDuplicateResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardNameDuplicateResponse.kt new file mode 100644 index 00000000..9daca5d9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardNameDuplicateResponse.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.board.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class BoardNameDuplicateResponse( + @field:Schema(description = "게시판 이름 중복 여부", example = "true") + val duplicated: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt new file mode 100644 index 00000000..1ca99440 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt @@ -0,0 +1,36 @@ +package com.weeth.domain.board.application.dto.response + +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.user.application.dto.response.UserInfo +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class PostDetailResponse( + @field:Schema(description = "게시글 ID") + val id: Long, + @field:Schema(description = "게시판 ID") + val boardId: Long, + @field:Schema(description = "게시판 이름") + val boardName: String, + @field:Schema(description = "작성자 정보") + val author: UserInfo, + @field:Schema(description = "제목") + val title: String, + @field:Schema(description = "내용") + val content: String, + @field:Schema(description = "수정 시각") + val time: LocalDateTime, + @field:Schema(description = "댓글 수") + val commentCount: Int, + @field:Schema(description = "좋아요 정보") + val like: PostLikeResponse, + @field:Schema(description = "댓글 목록") + val comments: List, + @field:Schema(description = "첨부 파일 목록") + val fileUrls: List, + @field:Schema(description = "신규 게시글 여부 (24시간 이내)") + val isNew: Boolean, + @field:Schema(description = "게시판 설정") + val boardConfig: BoardConfigResponse, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeActionResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeActionResponse.kt new file mode 100644 index 00000000..2e76510d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeActionResponse.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.board.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PostLikeActionResponse( + @field:Schema(description = "게시판 ID", example = "1") + val boardId: Long, + @field:Schema(description = "좋아요 여부", example = "true") + val isLiked: Boolean, + @field:Schema(description = "좋아요 수", example = "5") + val likeCount: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeResponse.kt new file mode 100644 index 00000000..8b91cd3c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.board.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PostLikeResponse( + @field:Schema(description = "좋아요 여부", example = "true") + val isLiked: Boolean, + @field:Schema(description = "좋아요 수", example = "5") + val likeCount: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt new file mode 100644 index 00000000..90247aa2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt @@ -0,0 +1,33 @@ +package com.weeth.domain.board.application.dto.response + +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.user.application.dto.response.UserInfo +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class PostListResponse( + @field:Schema(description = "게시글 ID") + val id: Long, + @field:Schema(description = "작성자 정보") + val author: UserInfo, + @field:Schema(description = "게시판 ID") + val boardId: Long, + @field:Schema(description = "게시판 이름") + val boardName: String, + @field:Schema(description = "제목") + val title: String, + @field:Schema(description = "내용") + val content: String, + @field:Schema(description = "수정 시각") + val time: LocalDateTime, + @field:Schema(description = "댓글 수") + val commentCount: Int, + @field:Schema(description = "좋아요 정보") + val like: PostLikeResponse, + @field:Schema(description = "첨부 파일 목록") + val fileUrls: List, + @field:Schema(description = "신규 게시글 여부 (24시간 이내)") + val isNew: Boolean, + @field:Schema(description = "게시판 설정") + val boardConfig: BoardConfigResponse, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt new file mode 100644 index 00000000..450713d9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.board.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PostSaveResponse( + @field:Schema(description = "게시글 ID", example = "1") + val id: Long, + @field:Schema(description = "게시판 ID", example = "1") + val boardId: Long, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardCreateLockTimeoutException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardCreateLockTimeoutException.kt new file mode 100644 index 00000000..150ce725 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardCreateLockTimeoutException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class BoardCreateLockTimeoutException : BaseException(BoardErrorCode.BOARD_CREATE_LOCK_TIMEOUT) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt new file mode 100644 index 00000000..fb1fc39b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt @@ -0,0 +1,62 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class BoardErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("검색 결과가 없을 때 발생합니다.") + NO_SEARCH_RESULT(20400, HttpStatus.NOT_FOUND, "검색 결과가 없습니다."), + + @ExplainError("유효하지 않은 페이지 번호를 요청할 때 발생합니다.") + PAGE_NOT_FOUND(20401, HttpStatus.BAD_REQUEST, "유효하지 않은 페이지입니다."), + + @ExplainError("ADMIN 전용 게시판에 일반 사용자가 글을 작성할 때 발생합니다.") + CATEGORY_ACCESS_DENIED(20402, HttpStatus.FORBIDDEN, "해당 카테고리에 대한 권한이 없습니다."), + + @ExplainError("게시판 ID로 조회했으나 해당 게시판이 존재하지 않거나 동아리에 속하지 않는 경우에 발생합니다.") + BOARD_NOT_FOUND(20403, HttpStatus.NOT_FOUND, "존재하지 않는 게시판입니다."), + + @ExplainError("게시글 ID로 조회했으나 해당 게시글이 존재하지 않을 때 발생합니다.") + POST_NOT_FOUND(20404, HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."), + + @ExplainError("게시글 작성자가 아닌 사용자가 수정/삭제를 시도할 때 발생합니다.") + POST_NOT_OWNED(20405, HttpStatus.FORBIDDEN, "게시글 작성자만 수정/삭제할 수 있습니다."), + + @ExplainError("공지 게시판이 아닌 게시판에 읽음 처리를 시도할 때 발생합니다.") + BOARD_TYPE_MISMATCH(20406, HttpStatus.BAD_REQUEST, "공지 게시판이 아닙니다."), + + @ExplainError("경로의 clubId와 게시판의 소속 클럽이 일치하지 않을 때 발생합니다.") + BOARD_NOT_IN_CLUB(20407, HttpStatus.FORBIDDEN, "해당 클럽에 속한 게시판이 아닙니다."), + + @ExplainError("순서 변경 요청에 중복된 게시판 ID가 포함되어 있을 때 발생합니다.") + DUPLICATE_BOARD_ID(20408, HttpStatus.BAD_REQUEST, "중복된 게시판 ID가 포함되어 있습니다."), + + @ExplainError("동일한 클럽 내에 같은 이름의 게시판이 이미 존재할 때 발생합니다.") + DUPLICATE_BOARD_NAME(20409, HttpStatus.CONFLICT, "이미 존재하는 게시판 이름입니다."), + + @ExplainError("공지사항 등 고정 게시판을 순서 변경 요청에 포함할 때 발생합니다.") + FIXED_BOARD_NOT_REORDERABLE(20410, HttpStatus.BAD_REQUEST, "고정 게시판은 순서를 변경할 수 없습니다."), + + @ExplainError("공지사항 등 고정 게시판의 이름 변경을 시도할 때 발생합니다.") + FIXED_BOARD_NOT_RENAMABLE(20411, HttpStatus.BAD_REQUEST, "고정 게시판의 이름은 변경할 수 없습니다."), + + @ExplainError("삭제된 게시판을 순서 변경 요청에 포함할 때 발생합니다.") + DELETED_BOARD_NOT_REORDERABLE(20412, HttpStatus.BAD_REQUEST, "삭제된 게시판의 순서는 변경할 수 없습니다."), + + @ExplainError("좋아요 처리 중 동시 요청이 많아 락 획득에 실패했을 때 발생합니다.") + POST_LIKE_LOCK_TIMEOUT(20413, HttpStatus.TOO_MANY_REQUESTS, "잠시 후 다시 시도해주세요."), + + @ExplainError("공지사항 게시판은 필수 게시판으로 삭제할 수 없을 때 발생합니다.") + FIXED_BOARD_NOT_DELETABLE(20414, HttpStatus.BAD_REQUEST, "공지사항 게시판은 삭제할 수 없습니다."), + + @ExplainError("동아리 게시판 생성 가능 개수를 초과했을 때 발생합니다.") + BOARD_LIMIT_EXCEEDED(20415, HttpStatus.BAD_REQUEST, "게시판 생성 가능한 횟수를 초과했습니다."), + + @ExplainError("게시판 생성 중 동시 요청이 많아 락 획득에 실패했을 때 발생합니다.") + BOARD_CREATE_LOCK_TIMEOUT(20416, HttpStatus.TOO_MANY_REQUESTS, "잠시 후 다시 시도해주세요."), +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardLimitExceededException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardLimitExceededException.kt new file mode 100644 index 00000000..cace1ca0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardLimitExceededException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class BoardLimitExceededException : BaseException(BoardErrorCode.BOARD_LIMIT_EXCEEDED) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotFoundException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotFoundException.kt new file mode 100644 index 00000000..5bfd3f72 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class BoardNotFoundException : BaseException(BoardErrorCode.BOARD_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotInClubException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotInClubException.kt new file mode 100644 index 00000000..fb91f82e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotInClubException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class BoardNotInClubException : BaseException(BoardErrorCode.BOARD_NOT_IN_CLUB) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardTypeMismatchException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardTypeMismatchException.kt new file mode 100644 index 00000000..1b6042df --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardTypeMismatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class BoardTypeMismatchException : BaseException(BoardErrorCode.BOARD_TYPE_MISMATCH) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.kt new file mode 100644 index 00000000..4ef91e1e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class CategoryAccessDeniedException : BaseException(BoardErrorCode.CATEGORY_ACCESS_DENIED) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/DeletedBoardNotReorderableException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/DeletedBoardNotReorderableException.kt new file mode 100644 index 00000000..21f12c3f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/DeletedBoardNotReorderableException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class DeletedBoardNotReorderableException : BaseException(BoardErrorCode.DELETED_BOARD_NOT_REORDERABLE) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardIdException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardIdException.kt new file mode 100644 index 00000000..c5abc650 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardIdException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class DuplicateBoardIdException : BaseException(BoardErrorCode.DUPLICATE_BOARD_ID) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardNameException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardNameException.kt new file mode 100644 index 00000000..d96ef422 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardNameException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class DuplicateBoardNameException : BaseException(BoardErrorCode.DUPLICATE_BOARD_NAME) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotDeletableException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotDeletableException.kt new file mode 100644 index 00000000..3d74aed8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotDeletableException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class FixedBoardNotDeletableException : BaseException(BoardErrorCode.FIXED_BOARD_NOT_DELETABLE) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotRenamableException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotRenamableException.kt new file mode 100644 index 00000000..e4c9e8cb --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotRenamableException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class FixedBoardNotRenamableException : BaseException(BoardErrorCode.FIXED_BOARD_NOT_RENAMABLE) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotReorderableException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotReorderableException.kt new file mode 100644 index 00000000..b13f8ac1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotReorderableException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class FixedBoardNotReorderableException : BaseException(BoardErrorCode.FIXED_BOARD_NOT_REORDERABLE) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/NoSearchResultException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/NoSearchResultException.kt new file mode 100644 index 00000000..0dd443b4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/NoSearchResultException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class NoSearchResultException : BaseException(BoardErrorCode.NO_SEARCH_RESULT) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/PageNotFoundException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/PageNotFoundException.kt new file mode 100644 index 00000000..d14fd215 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/PageNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class PageNotFoundException : BaseException(BoardErrorCode.PAGE_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/PostLikeLockTimeoutException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/PostLikeLockTimeoutException.kt new file mode 100644 index 00000000..e01bf83a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/PostLikeLockTimeoutException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class PostLikeLockTimeoutException : BaseException(BoardErrorCode.POST_LIKE_LOCK_TIMEOUT) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotFoundException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotFoundException.kt new file mode 100644 index 00000000..1870190a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class PostNotFoundException : BaseException(BoardErrorCode.POST_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotOwnedException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotOwnedException.kt new file mode 100644 index 00000000..cbd1bb1c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotOwnedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class PostNotOwnedException : BaseException(BoardErrorCode.POST_NOT_OWNED) diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt new file mode 100644 index 00000000..d0481375 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt @@ -0,0 +1,50 @@ +package com.weeth.domain.board.application.mapper + +import com.weeth.domain.board.application.dto.response.BoardConfigResponse +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.dto.response.BoardListResponse +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.club.domain.enums.MemberRole +import org.springframework.stereotype.Component + +@Component +class BoardMapper { + fun toListResponse( + board: Board, + memberRole: MemberRole, + ) = BoardListResponse( + id = board.id, + name = board.name, + type = board.type, + boardConfig = BoardConfigResponse.of(board, memberRole), + ) + + fun toDetailResponse(board: Board) = + BoardDetailResponse( + id = board.id, + name = board.name, + description = board.description, + type = board.type, + commentEnabled = board.config.commentEnabled, + writePermission = board.config.writePermission, + isPrivate = board.config.isPrivate, + displayOrder = board.displayOrder, + isDeleted = null, // public api에서 삭제 여부는 보여주지 않음 + ) + + fun toDetailResponseForAdmin( + board: Board, + postCount: Int? = null, + ) = BoardDetailResponse( + id = board.id, + name = board.name, + description = board.description, + type = board.type, + commentEnabled = board.config.commentEnabled, + writePermission = board.config.writePermission, + isPrivate = board.config.isPrivate, + displayOrder = board.displayOrder, + postCount = postCount, + isDeleted = board.isDeleted, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt new file mode 100644 index 00000000..af1d6344 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -0,0 +1,81 @@ +package com.weeth.domain.board.application.mapper + +import com.weeth.domain.board.application.dto.response.BoardConfigResponse +import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostLikeActionResponse +import com.weeth.domain.board.application.dto.response.PostLikeResponse +import com.weeth.domain.board.application.dto.response.PostListResponse +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.user.application.dto.response.UserInfo +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class PostMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { + fun toSaveResponse(post: Post) = PostSaveResponse(id = post.id, boardId = post.board.id) + + fun toLikeResponse( + post: Post, + isLiked: Boolean, + ) = PostLikeResponse(isLiked = isLiked, likeCount = post.likeCount) + + fun toLikeActionResponse( + post: Post, + isLiked: Boolean, + ) = PostLikeActionResponse(boardId = post.board.id, isLiked = isLiked, likeCount = post.likeCount) + + fun toDetailResponse( + post: Post, + comments: List, + files: List, + isLiked: Boolean, + now: LocalDateTime, + memberRole: MemberRole, + ) = PostDetailResponse( + id = post.id, + boardId = post.board.id, + boardName = post.board.name, + author = UserInfo.of(post.clubMember.user, post.clubMember.memberRole, resolveProfileImage(post.clubMember)), + title = post.title, + content = post.content, + time = post.modifiedAt, + commentCount = post.commentCount, + like = toLikeResponse(post, isLiked), + comments = comments, + fileUrls = files, + isNew = post.createdAt.isAfter(now.minusHours(24)), + boardConfig = BoardConfigResponse.of(post.board, memberRole), + ) + + fun toListResponse( + post: Post, + files: List, + now: LocalDateTime, + isLiked: Boolean, + memberRole: MemberRole, + ) = PostListResponse( + id = post.id, + author = UserInfo.of(post.clubMember.user, post.clubMember.memberRole, resolveProfileImage(post.clubMember)), + boardId = post.board.id, + boardName = post.board.name, + title = post.title, + content = post.content, + time = post.modifiedAt, + commentCount = post.commentCount, + like = toLikeResponse(post, isLiked), + fileUrls = files, + isNew = post.createdAt.isAfter(now.minusHours(24)), + boardConfig = BoardConfigResponse.of(post.board, memberRole), + ) + + private fun resolveProfileImage(member: ClubMember): String? = + member.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) } +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt new file mode 100644 index 00000000..8197d8a6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -0,0 +1,182 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.ReorderBoardsRequest +import com.weeth.domain.board.application.dto.request.UpdateBoardRequest +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.exception.BoardCreateLockTimeoutException +import com.weeth.domain.board.application.exception.BoardLimitExceededException +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.BoardNotInClubException +import com.weeth.domain.board.application.exception.DeletedBoardNotReorderableException +import com.weeth.domain.board.application.exception.DuplicateBoardIdException +import com.weeth.domain.board.application.exception.DuplicateBoardNameException +import com.weeth.domain.board.application.exception.FixedBoardNotDeletableException +import com.weeth.domain.board.application.exception.FixedBoardNotRenamableException +import com.weeth.domain.board.application.exception.FixedBoardNotReorderableException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import org.springframework.dao.PessimisticLockingFailureException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageBoardUseCase( + private val boardRepository: BoardRepository, + private val boardMapper: BoardMapper, + private val clubReader: ClubReader, + private val clubPermissionPolicy: ClubPermissionPolicy, +) { + @Transactional + fun create( + clubId: Long, + request: CreateBoardRequest, + userId: Long, + ): BoardDetailResponse { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val club = + try { + clubReader.getClubByIdForUpdate(clubId) + } catch (_: PessimisticLockingFailureException) { + throw BoardCreateLockTimeoutException() + } + + // TODO: MVP 제약 — 공지사항은 클럽 생성 시 자동 제공되므로 직접 생성 불가. 다중 NOTICE 지원 시 제거 + if (request.type == BoardType.NOTICE) throw BoardLimitExceededException() + + if (boardRepository.countByClubIdAndIsDeletedFalse(clubId) >= MAX_BOARD_COUNT) { + throw BoardLimitExceededException() + } + + if (boardRepository.existsByClubIdAndNameAndIsDeletedFalse( + clubId, + request.name, + ) + ) { + throw DuplicateBoardNameException() + } + + val nextOrder = boardRepository.findMaxActiveDisplayOrderByClubId(clubId) + 1 + val board = + Board( + club = club, + name = request.name, + description = request.description, + type = request.type, + config = + BoardConfig( + commentEnabled = request.commentEnabled, + writePermission = request.writePermission, + isPrivate = request.isPrivate, + ), + ) + board.reorder(nextOrder) + + val savedBoard = boardRepository.save(board) + return boardMapper.toDetailResponse(savedBoard) + } + + @Transactional + fun update( + clubId: Long, + boardId: Long, + request: UpdateBoardRequest, + userId: Long, + ): BoardDetailResponse { + clubPermissionPolicy.requireAdmin(clubId, userId) + val board = findBoard(boardId) + if (board.club.id != clubId) throw BoardNotFoundException() + + request.name?.let { + if (board.type == BoardType.NOTICE) throw FixedBoardNotRenamableException() + if (boardRepository.existsByClubIdAndNameAndIsDeletedFalseAndIdNot( + clubId, + it, + boardId, + ) + ) { + throw DuplicateBoardNameException() + } + board.rename(it) + } + + request.description?.let(board::updateDescription) + + // BoardConfig는 불변 VO이므로 개별 필드 수정이 불가능하여 copy()로 새 객체를 만들어 통째로 교체한다. null이면 기존 값을 명시적으로 채운다. + // 바깥 if 문은 config 관련 필드가 전부 null인 요청에서 불필요한 VO 생성을 방지한다. + if (request.commentEnabled != null || request.writePermission != null || request.isPrivate != null) { + board.updateConfig( + board.config.copy( + commentEnabled = request.commentEnabled ?: board.config.commentEnabled, + writePermission = request.writePermission ?: board.config.writePermission, + isPrivate = request.isPrivate ?: board.config.isPrivate, + ), + ) + } + + return boardMapper.toDetailResponse(board) + } + + @Transactional + fun delete( + clubId: Long, + boardId: Long, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val board = findBoard(boardId) + + if (board.club.id != clubId) throw BoardNotFoundException() + if (board.type == BoardType.NOTICE) throw FixedBoardNotDeletableException() + val maxOrder = boardRepository.findMaxDisplayOrderByClubId(clubId) + board.markDeleted() + board.reorder(maxOrder + 1) + } + + @Transactional + fun reorder( + clubId: Long, + request: ReorderBoardsRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val uniqueIds = request.boardIds.toSet() + if (uniqueIds.size != request.boardIds.size) throw DuplicateBoardIdException() + + val allBoards = boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + val (activeBoards, deletedBoards) = allBoards.partition { !it.isDeleted } + + // 삭제된 게시판 ID가 요청에 포함되면 명확한 에러 반환 + val deletedIds = deletedBoards.mapTo(mutableSetOf()) { it.id } + if (uniqueIds.any { it in deletedIds }) throw DeletedBoardNotReorderableException() + + val (fixedBoards, reorderableBoards) = activeBoards.partition { it.type == BoardType.NOTICE } + + // 고정 게시판 ID가 요청에 포함되면 명확한 에러 반환 + val fixedIds = fixedBoards.mapTo(mutableSetOf()) { it.id } + if (uniqueIds.any { it in fixedIds }) throw FixedBoardNotReorderableException() + + val boardById = reorderableBoards.associateBy { it.id } + if (uniqueIds.any { it !in boardById }) throw BoardNotInClubException() + + // 요청된 게시판들의 현재 displayOrder 슬롯을 정렬 후 재배분 (부분 재정렬 시 충돌 방지) + val slots = request.boardIds.map { boardById.getValue(it).displayOrder }.sorted() + request.boardIds.forEachIndexed { index, boardId -> + boardById.getValue(boardId).reorder(slots[index]) + } + } + + private fun findBoard(boardId: Long): Board = + boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + + companion object { + private const val MAX_BOARD_COUNT = 4 + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCase.kt new file mode 100644 index 00000000..3c795ac7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCase.kt @@ -0,0 +1,86 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.response.PostLikeActionResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.CategoryAccessDeniedException +import com.weeth.domain.board.application.exception.PostLikeLockTimeoutException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.PostLike +import com.weeth.domain.board.domain.repository.PostLikeRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import org.springframework.dao.PessimisticLockingFailureException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManagePostLikeUseCase( + private val postRepository: PostRepository, + private val postLikeRepository: PostLikeRepository, + private val clubMemberPolicy: ClubMemberPolicy, + private val postMapper: PostMapper, +) { + @Transactional + fun like( + clubId: Long, + boardId: Long, + postId: Long, + userId: Long, + ): PostLikeActionResponse { + val (post, existingLike) = getValidatedPostWithLike(clubId, boardId, postId, userId) + + when { + existingLike == null -> { + postLikeRepository.save(PostLike(post = post, userId = userId)) + post.increaseLikeCount() + } + + !existingLike.isActive -> { + existingLike.activate() + post.increaseLikeCount() + } + } + + return postMapper.toLikeActionResponse(post, isLiked = true) + } + + @Transactional + fun unlike( + clubId: Long, + boardId: Long, + postId: Long, + userId: Long, + ): PostLikeActionResponse { + val (post, existingLike) = getValidatedPostWithLike(clubId, boardId, postId, userId) + + if (existingLike?.isActive == true) { + existingLike.deactivate() + post.decreaseLikeCount() + } + + return postMapper.toLikeActionResponse(post, isLiked = false) + } + + private fun getValidatedPostWithLike( + clubId: Long, + boardId: Long, + postId: Long, + userId: Long, + ): Pair { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val post = + try { + postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() + } catch (_: PessimisticLockingFailureException) { + throw PostLikeLockTimeoutException() + } + + if (post.board.id != boardId) throw BoardNotFoundException() + if (!post.belongsToClub(clubId)) throw PostNotFoundException() + if (!post.board.isAccessibleBy(member.memberRole)) throw CategoryAccessDeniedException() + + return post to postLikeRepository.findByPostAndUserId(post, userId) + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt new file mode 100644 index 00000000..cb434657 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -0,0 +1,164 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreatePostRequest +import com.weeth.domain.board.application.dto.request.UpdatePostRequest +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.CategoryAccessDeniedException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.exception.PostNotOwnedException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManagePostUseCase( + private val postRepository: PostRepository, + private val boardRepository: BoardRepository, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubMemberCardinalReader: ClubMemberCardinalReader, + private val fileRepository: FileRepository, + private val fileReader: FileReader, + private val fileMapper: FileMapper, + private val postMapper: PostMapper, +) { + @Transactional + fun save( + clubId: Long, + boardId: Long, + request: CreatePostRequest, + userId: Long, + ): PostSaveResponse { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val board = findBoardInClub(boardId, clubId) + validateWritePermission(board, member) + + val currentCardinalNumber = + clubMemberCardinalReader + .findLatestCardinalByClubMember(member) + ?.cardinal + ?.cardinalNumber + val post = + Post.create( + title = request.title, + content = request.content, + clubMember = member, + board = board, + cardinalNumber = currentCardinalNumber, + ) + + val savedPost = postRepository.save(post) + savePostFiles(savedPost, request.files) + return postMapper.toSaveResponse(savedPost) + } + + @Transactional + fun update( + clubId: Long, + boardId: Long, + postId: Long, + request: UpdatePostRequest, + userId: Long, + ): PostSaveResponse { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val post = findPost(postId) + if (post.board.id != boardId) throw BoardNotFoundException() + if (post.board.club.id != clubId) throw PostNotFoundException() + validateOwner(post, userId) + validateWritePermission(post.board, member) + + post.update( + newTitle = request.title, + newContent = request.content, + ) + + replacePostFiles(post, request.files) + return postMapper.toSaveResponse(post) + } + + @Transactional + fun delete( + clubId: Long, + boardId: Long, + postId: Long, + userId: Long, + ) { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val post = findPost(postId) + if (post.board.id != boardId) throw BoardNotFoundException() + if (post.board.club.id != clubId) throw PostNotFoundException() + validateOwner(post, userId) + validateWritePermission(post.board, member) + + deletePostFiles(post.id) + post.markDeleted() + } + + private fun findBoardInClub( + boardId: Long, + clubId: Long, + ): Board = boardRepository.findByIdAndClubIdAndIsDeletedFalse(boardId, clubId) ?: throw BoardNotFoundException() + + private fun findPost(postId: Long): Post = + postRepository.findActivePostById(postId) ?: throw PostNotFoundException() + + private fun validateOwner( + post: Post, + userId: Long, + ) { + if (!post.isOwnedBy(userId)) { + throw PostNotOwnedException() + } + } + + private fun validateWritePermission( + board: Board, + member: ClubMember, + ) { + if (!board.canWriteBy(member.memberRole)) { + throw CategoryAccessDeniedException() + } + } + + private fun replacePostFiles( + post: Post, + files: List?, + ) { + if (files == null) { + return + } + deletePostFiles(post.id) + savePostFiles(post, files) + } + + private fun savePostFiles( + post: Post, + files: List?, + ) { + val mappedFiles = fileMapper.toFileList(files, FileOwnerType.POST, post.id) + if (mappedFiles.isNotEmpty()) { + fileRepository.saveAll(mappedFiles) + } + } + + private fun deletePostFiles(postId: Long) { + val files = fileReader.findAll(FileOwnerType.POST, postId) + + if (files.isNotEmpty()) { + fileRepository.deleteAll(files) + fileRepository.flush() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCase.kt new file mode 100644 index 00000000..1f41eebd --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCase.kt @@ -0,0 +1,48 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.BoardNotInClubException +import com.weeth.domain.board.application.exception.BoardTypeMismatchException +import com.weeth.domain.board.domain.entity.LastNoticeRead +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.LastNoticeReadReader +import com.weeth.domain.board.domain.repository.LastNoticeReadRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class MarkNoticeReadUseCase( + private val boardRepository: BoardRepository, + private val lastNoticeReadReader: LastNoticeReadReader, + private val lastNoticeReadRepository: LastNoticeReadRepository, + private val userReader: UserReader, + private val clubMemberPolicy: ClubMemberPolicy, +) { + @Transactional + fun execute( + userId: Long, + clubId: Long, + boardId: Long, + ) { + clubMemberPolicy.getActiveMember(clubId, userId) + + val board = + boardRepository.findByIdAndIsDeletedFalse(boardId) + ?: throw BoardNotFoundException() + if (board.club.id != clubId) throw BoardNotInClubException() + if (board.type != BoardType.NOTICE) throw BoardTypeMismatchException() + + val existing = lastNoticeReadReader.findByUserIdAndBoardId(userId, boardId) + if (existing != null) { + existing.updateLastReadAt(LocalDateTime.now()) + return + } + + val user = userReader.getById(userId) + lastNoticeReadRepository.save(LastNoticeRead.create(user = user, board = board)) + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt new file mode 100644 index 00000000..bb85798e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -0,0 +1,127 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.dto.response.BoardConfigResponse +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.dto.response.BoardListResponse +import com.weeth.domain.board.application.dto.response.BoardNameDuplicateResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetBoardQueryService( + private val boardRepository: BoardRepository, + private val postRepository: PostRepository, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val boardMapper: BoardMapper, +) { + fun findBoards( + clubId: Long, + userId: Long, + ): List { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + + val realBoards = + boardRepository + .findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) + .filter { it.isAccessibleBy(member.memberRole) } + + // 공지사항 고정 첫 번째, 전체(가상) 두 번째, 나머지는 displayOrder 순 + val memberRole = member.memberRole + val (noticeList, otherList) = realBoards.partition { it.type == BoardType.NOTICE } + val noticeBoards = noticeList.map { boardMapper.toListResponse(it, memberRole) } + val otherBoards = otherList.map { boardMapper.toListResponse(it, memberRole) } + + return noticeBoards + VIRTUAL_ALL_BOARD + otherBoards + } + + fun findBoardDetailForAdmin( + clubId: Long, + userId: Long, + boardId: Long, + ): BoardDetailResponse { + clubPermissionPolicy.requireAdmin(clubId, userId) + val board = boardRepository.findByIdAndClubId(boardId, clubId) ?: throw BoardNotFoundException() + val postCount = + postRepository + .countActivePostsByBoardIds(listOf(boardId)) + .firstOrNull() + ?.postCount + ?.toInt() ?: 0 + + return boardMapper.toDetailResponseForAdmin(board, postCount) + } + + fun findAllBoardsForAdmin( + clubId: Long, + userId: Long, + ): List { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val boards = boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + val boardIds = boards.map { it.id } + val postCountMap = + if (boardIds.isEmpty()) { + emptyMap() + } else { + postRepository.countActivePostsByBoardIds(boardIds).associate { it.boardId to it.postCount.toInt() } + } + + val (noticeList, otherList) = boards.partition { it.type == BoardType.NOTICE } + val noticeBoards = noticeList.map { boardMapper.toDetailResponseForAdmin(it, postCountMap[it.id] ?: 0) } + val otherBoards = otherList.map { boardMapper.toDetailResponseForAdmin(it, postCountMap[it.id] ?: 0) } + val totalPostCount = postCountMap.values.sum() + + return noticeBoards + virtualAllBoardForAdmin(totalPostCount) + otherBoards + } + + fun checkBoardNameDuplicate( + clubId: Long, + userId: Long, + name: String, + boardId: Long? = null, + ): BoardNameDuplicateResponse { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val duplicated = + if (boardId == null) { + boardRepository.existsByClubIdAndNameAndIsDeletedFalse(clubId, name) + } else { + boardRepository.existsByClubIdAndNameAndIsDeletedFalseAndIdNot(clubId, name, boardId) + } + + return BoardNameDuplicateResponse(duplicated = duplicated) + } + + companion object { + private val VIRTUAL_ALL_BOARD = + BoardListResponse( + id = null, + name = "전체", + type = BoardType.ALL, + boardConfig = BoardConfigResponse(canWrite = false, canComment = false), + ) + + private fun virtualAllBoardForAdmin(totalPostCount: Int) = + BoardDetailResponse( + id = null, + name = "전체", + description = "모든 게시글을 확인할 수 있는 게시판입니다.", // 우선 통일을 위해 백엔드에서 설정, 추후 변경될 수 있음 + type = BoardType.ALL, + commentEnabled = null, + writePermission = null, + isPrivate = null, + displayOrder = null, + postCount = totalPostCount, + isDeleted = null, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt new file mode 100644 index 00000000..fdfa6df1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -0,0 +1,180 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostListResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.NoSearchResultException +import com.weeth.domain.board.application.exception.PageNotFoundException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostLikeRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService +import com.weeth.domain.comment.domain.repository.CommentReader +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Slice +import org.springframework.data.domain.SliceImpl +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class GetPostQueryService( + private val postRepository: PostRepository, + private val boardRepository: BoardRepository, + private val postLikeRepository: PostLikeRepository, + private val clubMemberPolicy: ClubMemberPolicy, + private val commentReader: CommentReader, + private val getCommentQueryService: GetCommentQueryService, + private val fileReader: FileReader, + private val fileMapper: FileMapper, + private val postMapper: PostMapper, +) { + companion object { + private const val MAX_PAGE_SIZE = 50 + } + + fun findPost( + clubId: Long, + userId: Long, + boardId: Long, + postId: Long, + ): PostDetailResponse { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() + + if (post.board.id != boardId) throw BoardNotFoundException() + if (post.board.club.id != clubId || post.board.isDeleted || !post.board.isAccessibleBy(member.memberRole)) { + throw PostNotFoundException() + } + + val files = fileReader.findAll(FileOwnerType.POST, post.id).map(fileMapper::toFileResponse) + val comments = commentReader.findAllByPostId(post.id) + val commentTree = getCommentQueryService.toCommentTreeResponses(comments) + val isLiked = postLikeRepository.existsByPostAndUserIdAndIsActiveTrue(post, userId) + val now = LocalDateTime.now() + + return postMapper.toDetailResponse(post, commentTree, files, isLiked, now, member.memberRole) + } + + fun findAllPosts( + clubId: Long, + userId: Long, + pageNumber: Int, + pageSize: Int, + ): Slice { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + validatePage(pageNumber, pageSize) + + val accessibleBoardIds = + boardRepository + .findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) + .filter { it.isAccessibleBy(member.memberRole) } + .map { it.id } + + val pageable = PageRequest.of(pageNumber, pageSize) + + if (accessibleBoardIds.isEmpty()) { + return SliceImpl(emptyList(), pageable, false) + } + + val posts = postRepository.findAllActiveByBoardIds(accessibleBoardIds, pageable) + + return toPostListResponses(posts, userId, member.memberRole) + } + + fun findPosts( + clubId: Long, + userId: Long, + boardId: Long, + pageNumber: Int, + pageSize: Int, + ): Slice { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + validatePage(pageNumber, pageSize) + validateBoardVisibility(boardId, clubId, member.memberRole) + + val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) + val posts = postRepository.findAllActiveByBoardId(boardId, pageable) + + return toPostListResponses(posts, userId, member.memberRole) + } + + fun searchPosts( + clubId: Long, + userId: Long, + boardId: Long, + keyword: String, + pageNumber: Int, + pageSize: Int, + ): Slice { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + validatePage(pageNumber, pageSize) + validateBoardVisibility(boardId, clubId, member.memberRole) + val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) + val posts = postRepository.searchByBoardId(boardId, keyword.trim(), pageable) + + if (posts.isEmpty) { + throw NoSearchResultException() + } + + return toPostListResponses(posts, userId, member.memberRole) + } + + private fun toPostListResponses( + posts: Slice, + userId: Long, + memberRole: MemberRole, + ): Slice { + val postIds = posts.content.map { it.id } + val filesByPostId = buildFileMap(postIds) + val likedPostIds = postLikeRepository.findLikedPostIds(postIds, userId) + val now = LocalDateTime.now() + + return posts.map { post -> + postMapper.toListResponse( + post, + filesByPostId[post.id]?.map(fileMapper::toFileResponse) ?: emptyList(), + now, + post.id in likedPostIds, + memberRole, + ) + } + } + + private fun validatePage( + pageNumber: Int, + pageSize: Int, + ) { + if (pageNumber < 0 || pageSize !in 1..MAX_PAGE_SIZE) { + throw PageNotFoundException() + } + } + + private fun buildFileMap(postIds: List): Map> { + if (postIds.isEmpty()) return emptyMap() + return fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } + } + + private fun validateBoardVisibility( // todo: 볼 권한이 없는 경우 권한 관련 예외를 던져주는게 나을지 UX 상의 후 결정 + boardId: Long, + clubId: Long, + memberRole: MemberRole, + ) { + val board = + boardRepository.findByIdAndClubIdAndIsDeletedFalse(boardId, clubId) ?: throw BoardNotFoundException() + if (!board.isAccessibleBy(memberRole)) { + throw BoardNotFoundException() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverter.kt b/src/main/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverter.kt new file mode 100644 index 00000000..057fa9da --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverter.kt @@ -0,0 +1,9 @@ +package com.weeth.domain.board.domain.converter + +import com.fasterxml.jackson.core.type.TypeReference +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.global.common.converter.JsonConverter +import jakarta.persistence.Converter + +@Converter +class BoardConfigConverter : JsonConverter(object : TypeReference() {}) diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt new file mode 100644 index 00000000..d6c6dd44 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -0,0 +1,110 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.board.domain.converter.BoardConfigConverter +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table + +@Entity +@Table(name = "board") +class Board( + club: Club, + name: String, + description: String, + type: BoardType, + config: BoardConfig = BoardConfig(), +) : BaseEntity() { + init { + require(name.isNotBlank()) { "게시판 이름은 공백이 될 수 없습니다" } + require(description.isNotBlank()) { "게시판 설명은 공백이 될 수 없습니다" } + require(type != BoardType.ALL) { "ALL은 가상 타입으로 게시판을 생성할 수 없습니다" } + } + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + var club: Club = club + private set + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + @Column(nullable = false) + var name: String = name + private set + + @Column(nullable = false, length = 500) + var description: String = description + private set + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var type: BoardType = type + private set + + @Column(columnDefinition = "JSON") // Json 속성 사용으로 인한 커스텀 컨버터 적용 + @Convert(converter = BoardConfigConverter::class) + var config: BoardConfig = config + private set + + @Column(nullable = false) + var displayOrder: Int = 0 + private set + + @Column(nullable = false) + var isDeleted: Boolean = false + private set + + val isCommentEnabled: Boolean + get() = config.commentEnabled + + val isAdminOnly: Boolean + get() = config.writePermission.isAdminOrLead() + + fun isAccessibleBy(memberRole: MemberRole): Boolean = memberRole.isAdminOrLead() || !config.isPrivate + + fun canWriteBy(memberRole: MemberRole): Boolean = + isAccessibleBy(memberRole) && (memberRole.isAdminOrLead() || !isAdminOnly) + + fun updateConfig(newConfig: BoardConfig) { + config = newConfig + } + + fun rename(newName: String) { + require(newName.isNotBlank()) { "게시판 이름은 공백이 될 수 없습니다." } + name = newName + } + + fun updateDescription(newDescription: String) { + require(newDescription.isNotBlank()) { "게시판 설명은 공백이 될 수 없습니다." } + description = newDescription + } + + fun markDeleted() { + isDeleted = true + } + + fun restore() { + isDeleted = false + } + + fun reorder(newOrder: Int) { + require(newOrder >= 0) { "순서는 0 이상이어야 합니다." } + displayOrder = newOrder + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/LastNoticeRead.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/LastNoticeRead.kt new file mode 100644 index 00000000..cb052c3f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/LastNoticeRead.kt @@ -0,0 +1,54 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.user.domain.entity.User +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.LocalDateTime + +@Entity +@Table( + name = "last_notice_read", + uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "board_id"])], +) +class LastNoticeRead( + user: User, + board: Board, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + var user: User = user + private set + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id", nullable = false) + var board: Board = board + private set + + @Column(nullable = false) + var lastReadAt: LocalDateTime = LocalDateTime.now() + private set + + fun updateLastReadAt(time: LocalDateTime) { + lastReadAt = time + } + + companion object { + fun create( + user: User, + board: Board, + ) = LastNoticeRead(user = user, board = board) + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt new file mode 100644 index 00000000..7e949f69 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt @@ -0,0 +1,126 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table + +@Entity +@Table(name = "post") +class Post( + title: String, + content: String, + clubMember: ClubMember, + board: Board, + cardinalNumber: Int? = null, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + @Column(nullable = false) + var title: String = title + private set + + @Column(columnDefinition = "TEXT", nullable = false) + var content: String = content + private set + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "club_member_id", nullable = false) + var clubMember: ClubMember = clubMember + private set + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "board_id", nullable = false) + var board: Board = board + private set + + @Column(nullable = false) + var commentCount: Int = 0 + private set + + @Column(nullable = false) + var likeCount: Int = 0 + private set + + @Column + var cardinalNumber: Int? = cardinalNumber + private set + + @Column(nullable = false) + var isDeleted: Boolean = false + private set + + fun increaseCommentCount() { + commentCount++ + } + + fun decreaseCommentCount() { + check(commentCount > 0) { "댓글 수는 0보다 작아질 수 없습니다" } + commentCount-- + } + + fun increaseLikeCount() { + likeCount++ + } + + fun decreaseLikeCount() { + check(likeCount > 0) { "좋아요 수는 0보다 작아질 수 없습니다" } + likeCount-- + } + + fun isOwnedBy(userId: Long): Boolean = clubMember.user.id == userId + + fun belongsToClub(clubId: Long): Boolean = board.club.id == clubId && !board.isDeleted + + fun update( + newTitle: String?, + newContent: String?, + ) { + newTitle?.let { + require(it.isNotBlank()) { "제목은 비어 있을 수 없습니다" } + title = it + } + newContent?.let { + require(it.isNotBlank()) { "내용은 비어 있을 수 없습니다" } + content = it + } + } + + fun markDeleted() { + isDeleted = true + } + + fun restore() { + isDeleted = false + } + + companion object { + fun create( + title: String, + content: String, + clubMember: ClubMember, + board: Board, + cardinalNumber: Int? = null, + ): Post { + require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다" } + require(content.isNotBlank()) { "내용은 비어 있을 수 없습니다" } + return Post( + title = title, + content = content, + clubMember = clubMember, + board = board, + cardinalNumber = cardinalNumber, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/PostLike.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/PostLike.kt new file mode 100644 index 00000000..ca1e60cf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/PostLike.kt @@ -0,0 +1,49 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "post_like", + uniqueConstraints = [UniqueConstraint(columnNames = ["post_id", "user_id"])], +) +class PostLike( + post: Post, + userId: Long, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id", nullable = false) + var post: Post = post + private set + + @Column(nullable = false) + var userId: Long = userId + private set + + @Column(nullable = false) + var isActive: Boolean = true + private set + + fun activate() { + isActive = true + } + + fun deactivate() { + isActive = false + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/enums/BoardType.kt b/src/main/kotlin/com/weeth/domain/board/domain/enums/BoardType.kt new file mode 100644 index 00000000..ab17de71 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/enums/BoardType.kt @@ -0,0 +1,9 @@ +package com.weeth.domain.board.domain.enums + +enum class BoardType { + ALL, // 가상 전체 게시판 (DB에 저장되지 않음) + NOTICE, + GALLERY, + GENERAL, + INFORMATION, +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardPostCount.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardPostCount.kt new file mode 100644 index 00000000..66416359 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardPostCount.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.board.domain.repository + +data class BoardPostCount( + val boardId: Long, + val postCount: Long, +) diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardReader.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardReader.kt new file mode 100644 index 00000000..62b26aac --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardReader.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.Board + +interface BoardReader { + fun findAllActiveByClubId(clubId: Long): List +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt new file mode 100644 index 00000000..a552dbc2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt @@ -0,0 +1,47 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.Board +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface BoardRepository : + JpaRepository, + BoardReader { + fun findByIdAndIsDeletedFalse(id: Long): Board? + + fun findByIdAndClubId( + boardId: Long, + clubId: Long, + ): Board? + + fun findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId: Long): List + + override fun findAllActiveByClubId(clubId: Long): List = + findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) + + fun findByIdAndClubIdAndIsDeletedFalse( + boardId: Long, + clubId: Long, + ): Board? + + fun findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId: Long): List + + @Query("SELECT COALESCE(MAX(b.displayOrder), -1) FROM Board b WHERE b.club.id = :clubId AND b.isDeleted = false") + fun findMaxActiveDisplayOrderByClubId(clubId: Long): Int + + @Query("SELECT COALESCE(MAX(b.displayOrder), -1) FROM Board b WHERE b.club.id = :clubId") + fun findMaxDisplayOrderByClubId(clubId: Long): Int + + fun countByClubIdAndIsDeletedFalse(clubId: Long): Int + + fun existsByClubIdAndNameAndIsDeletedFalse( + clubId: Long, + name: String, + ): Boolean + + fun existsByClubIdAndNameAndIsDeletedFalseAndIdNot( + clubId: Long, + name: String, + id: Long, + ): Boolean +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadReader.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadReader.kt new file mode 100644 index 00000000..edd3fac0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadReader.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.LastNoticeRead + +interface LastNoticeReadReader { + fun findByUserIdAndBoardId( + userId: Long, + boardId: Long, + ): LastNoticeRead? +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadRepository.kt new file mode 100644 index 00000000..bbb241c8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadRepository.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.LastNoticeRead +import org.springframework.data.jpa.repository.JpaRepository + +interface LastNoticeReadRepository : + JpaRepository, + LastNoticeReadReader { + override fun findByUserIdAndBoardId( + userId: Long, + boardId: Long, + ): LastNoticeRead? +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeReader.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeReader.kt new file mode 100644 index 00000000..0804f775 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeReader.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.board.domain.repository + +interface PostLikeReader { + fun findLikedPostIds( + postIds: List, + userId: Long, + ): Set +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt new file mode 100644 index 00000000..1b988add --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt @@ -0,0 +1,34 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.PostLike +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface PostLikeRepository : + JpaRepository, + PostLikeReader { + fun existsByPostAndUserIdAndIsActiveTrue( + post: Post, + userId: Long, + ): Boolean + + fun findByPostAndUserId( + post: Post, + userId: Long, + ): PostLike? + + @Query( + """ + SELECT pl.post.id + FROM PostLike pl + WHERE pl.post.id IN :postIds + AND pl.userId = :userId + AND pl.isActive = true + """, + ) + override fun findLikedPostIds( + postIds: List, + userId: Long, + ): Set +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostReader.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostReader.kt new file mode 100644 index 00000000..0cac837d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostReader.kt @@ -0,0 +1,41 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.enums.BoardType +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import java.time.LocalDateTime + +interface PostReader { + fun getById(postId: Long): Post + + fun findActiveById(postId: Long): Post? + + fun findRecentByBoardType( + boardType: BoardType, + pageable: Pageable, + ): Slice + + fun findRecentExcludingBoardType( + excludedType: BoardType, + pageable: Pageable, + ): Slice + + fun findRecentByClubIdAndBoardType( + clubId: Long, + boardType: BoardType, + pageable: Pageable, + ): Slice + + fun findRecentByBoardIds( + boardIds: List, + pageable: Pageable, + ): Slice + + fun findFirstUnreadNoticeSince( + clubId: Long, + userId: Long, + boardType: BoardType, + since: LocalDateTime, + ): Post? +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt new file mode 100644 index 00000000..d15a994f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt @@ -0,0 +1,203 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.enums.BoardType +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import org.springframework.data.jpa.repository.EntityGraph +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.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface PostRepository : + JpaRepository, + PostReader { + @EntityGraph(attributePaths = ["clubMember", "clubMember.user", "board"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.id IN :boardIds + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.createdAt DESC, p.id DESC + """, + ) + fun findAllActiveByBoardIds( + @Param("boardIds") boardIds: List, + pageable: Pageable, + ): Slice + + @EntityGraph(attributePaths = ["clubMember", "clubMember.user", "board"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.id = :boardId + AND p.isDeleted = false + AND p.board.isDeleted = false + """, + ) + fun findAllActiveByBoardId( + @Param("boardId") boardId: Long, + pageable: Pageable, + ): Slice + + fun findByIdAndIsDeletedFalse(id: Long): Post? + + @EntityGraph(attributePaths = ["clubMember", "clubMember.user", "board"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.id = :id + AND p.isDeleted = false + AND p.board.isDeleted = false + """, + ) + fun findActivePostById( + @Param("id") id: Long, + ): Post? + + @EntityGraph(attributePaths = ["board", "board.club"]) + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + """ + SELECT p + FROM Post p + WHERE p.id = :id + AND p.isDeleted = false + AND p.board.isDeleted = false + """, + ) + fun findByIdWithLock( + @Param("id") id: Long, + ): Post? + + @EntityGraph(attributePaths = ["clubMember", "clubMember.user", "board"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.id = :boardId + AND p.isDeleted = false + AND p.board.isDeleted = false + AND (LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))) + """, + ) + fun searchByBoardId( + @Param("boardId") boardId: Long, + @Param("keyword") keyword: String, + pageable: Pageable, + ): Slice + + override fun getById(postId: Long): Post = findActivePostById(postId) ?: throw PostNotFoundException() + + override fun findActiveById(postId: Long): Post? = findActivePostById(postId) + + override fun findRecentByBoardIds( + boardIds: List, + pageable: Pageable, + ): Slice = findAllActiveByBoardIds(boardIds, pageable) + + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.type = :boardType + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.createdAt DESC, p.id DESC + """, + ) + override fun findRecentByBoardType( + @Param("boardType") boardType: BoardType, + pageable: Pageable, + ): Slice + + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.type <> :excludedType + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.createdAt DESC, p.id DESC + """, + ) + override fun findRecentExcludingBoardType( + @Param("excludedType") excludedType: BoardType, + pageable: Pageable, + ): Slice + + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.club.id = :clubId + AND p.board.type = :boardType + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.createdAt DESC, p.id DESC + """, + ) + override fun findRecentByClubIdAndBoardType( + @Param("clubId") clubId: Long, + @Param("boardType") boardType: BoardType, + pageable: Pageable, + ): Slice + + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) + @Query( + """ + SELECT p + FROM Post p + LEFT JOIN LastNoticeRead lr ON lr.user.id = :userId AND lr.board.id = p.board.id + WHERE p.board.club.id = :clubId + AND p.board.type = :boardType + AND p.isDeleted = false + AND p.board.isDeleted = false + AND p.createdAt >= :since + AND (lr IS NULL OR p.createdAt > lr.lastReadAt) + ORDER BY p.createdAt DESC, p.id DESC + """, + ) + fun findUnreadNoticeSince( + @Param("clubId") clubId: Long, + @Param("userId") userId: Long, + @Param("boardType") boardType: BoardType, + @Param("since") since: LocalDateTime, + pageable: Pageable, + ): List + + override fun findFirstUnreadNoticeSince( + clubId: Long, + userId: Long, + boardType: BoardType, + since: LocalDateTime, + ): Post? = findUnreadNoticeSince(clubId, userId, boardType, since, PageRequest.of(0, 1)).firstOrNull() + + @Query( + """ + SELECT new com.weeth.domain.board.domain.repository.BoardPostCount(p.board.id, COUNT(p)) + FROM Post p + WHERE p.board.id IN :boardIds + AND p.isDeleted = false + GROUP BY p.board.id + """, + ) + fun countActivePostsByBoardIds( + @Param("boardIds") boardIds: List, + ): List +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt new file mode 100644 index 00000000..6cad32e8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt @@ -0,0 +1,9 @@ +package com.weeth.domain.board.domain.vo + +import com.weeth.domain.club.domain.enums.MemberRole + +data class BoardConfig( + val commentEnabled: Boolean = true, + val writePermission: MemberRole = MemberRole.USER, + val isPrivate: Boolean = false, +) diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt new file mode 100644 index 00000000..7835ed3f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt @@ -0,0 +1,128 @@ +package com.weeth.domain.board.presentation + +import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.ReorderBoardsRequest +import com.weeth.domain.board.application.dto.request.UpdateBoardRequest +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.dto.response.BoardNameDuplicateResponse +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.board.application.usecase.command.ManageBoardUseCase +import com.weeth.domain.board.application.usecase.query.GetBoardQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +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 + +@Tag(name = "Board-Admin", description = "Board Admin API") +@RestController +@RequestMapping("/api/v4/admin/clubs/{clubId}/boards") +@ApiErrorCodeExample(BoardErrorCode::class) +class BoardAdminController( + private val manageBoardUseCase: ManageBoardUseCase, + private val getBoardQueryService: GetBoardQueryService, +) { + @GetMapping + @Operation(summary = "게시판 전체 목록 조회 (삭제/비공개 포함)") + fun findAllBoards( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + BoardResponseCode.BOARD_FIND_ALL_SUCCESS, + getBoardQueryService.findAllBoardsForAdmin(clubId, userId), + ) + + @GetMapping("/{boardId}") + @Operation(summary = "게시판 상세 조회 (삭제된 게시판 포함)") + fun findBoard( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.BOARD_FIND_BY_ID_SUCCESS, + getBoardQueryService.findBoardDetailForAdmin(clubId, userId, boardId), + ) + + @GetMapping("/name-duplicate") + @Operation(summary = "게시판 이름 중복 체크") + fun checkBoardNameDuplicate( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestParam name: String, + @RequestParam(required = false) boardId: Long?, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.BOARD_NAME_DUPLICATE_CHECK_SUCCESS, + getBoardQueryService.checkBoardNameDuplicate(clubId, userId, name, boardId), + ) + + @PostMapping + @Operation(summary = "게시판 생성") + fun createBoard( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestBody @Valid request: CreateBoardRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.BOARD_CREATED_SUCCESS, + manageBoardUseCase.create(clubId, request, userId), + ) + + @PatchMapping("/{boardId}") + @Operation(summary = "게시판 설정/이름 수정") + fun updateBoard( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @RequestBody @Valid request: UpdateBoardRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.BOARD_UPDATED_SUCCESS, + manageBoardUseCase.update(clubId, boardId, request, userId), + ) + + @PatchMapping("/order") + @Operation(summary = "게시판 순서 변경", description = "boardIds 배열의 순서대로 게시판 표시 순서를 저장합니다.") + fun reorderBoards( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestBody @Valid request: ReorderBoardsRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageBoardUseCase.reorder(clubId, request, userId) + return CommonResponse.success(BoardResponseCode.BOARD_REORDERED_SUCCESS) + } + + @DeleteMapping("/{boardId}") + @Operation(summary = "게시판 삭제") + fun deleteBoard( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageBoardUseCase.delete(clubId, boardId, userId) + return CommonResponse.success(BoardResponseCode.BOARD_DELETED_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt new file mode 100644 index 00000000..77a727fe --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt @@ -0,0 +1,36 @@ +package com.weeth.domain.board.presentation + +import com.weeth.domain.board.application.dto.response.BoardListResponse +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.board.application.usecase.query.GetBoardQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "BOARD", description = "게시판 API") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/boards") +@ApiErrorCodeExample(BoardErrorCode::class) +class BoardController( + private val getBoardQueryService: GetBoardQueryService, +) { + @GetMapping + @Operation(summary = "게시판 목록 조회") + fun findBoards( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + BoardResponseCode.BOARD_FIND_ALL_SUCCESS, + getBoardQueryService.findBoards(clubId, userId), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt new file mode 100644 index 00000000..de4a1bb6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.board.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class BoardResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + BOARD_CREATED_SUCCESS(10400, HttpStatus.OK, "게시판이 성공적으로 생성되었습니다."), + POST_CREATED_SUCCESS(10401, HttpStatus.OK, "게시글이 성공적으로 생성되었습니다."), + POST_UPDATED_SUCCESS(10402, HttpStatus.OK, "게시글이 성공적으로 수정되었습니다."), + POST_DELETED_SUCCESS(10403, HttpStatus.OK, "게시글이 성공적으로 삭제되었습니다."), + POST_FIND_ALL_SUCCESS(10404, HttpStatus.OK, "게시글 목록이 성공적으로 조회되었습니다."), + POST_FIND_BY_ID_SUCCESS(10405, HttpStatus.OK, "게시글이 성공적으로 조회되었습니다."), + POST_SEARCH_SUCCESS(10406, HttpStatus.OK, "게시글 검색 결과가 성공적으로 조회되었습니다."), + BOARD_UPDATED_SUCCESS(10407, HttpStatus.OK, "게시판이 성공적으로 수정되었습니다."), + BOARD_DELETED_SUCCESS(10408, HttpStatus.OK, "게시판이 성공적으로 삭제되었습니다."), + BOARD_FIND_ALL_SUCCESS(10409, HttpStatus.OK, "게시판 목록이 성공적으로 조회되었습니다."), + BOARD_FIND_BY_ID_SUCCESS(10410, HttpStatus.OK, "게시판이 성공적으로 조회되었습니다."), + BOARD_NOTICE_READ_SUCCESS(10411, HttpStatus.OK, "공지를 읽음 처리했습니다."), + POST_FIND_ALL_BY_CLUB_SUCCESS(10412, HttpStatus.OK, "전체 게시글 목록이 성공적으로 조회되었습니다."), + BOARD_REORDERED_SUCCESS(10413, HttpStatus.OK, "게시판 순서가 성공적으로 변경되었습니다."), + POST_LIKE_SUCCESS(10414, HttpStatus.OK, "게시글에 좋아요를 눌렀습니다."), + POST_UNLIKE_SUCCESS(10415, HttpStatus.OK, "게시글 좋아요를 취소했습니다."), + BOARD_NAME_DUPLICATE_CHECK_SUCCESS(10416, HttpStatus.OK, "게시판 이름 중복 여부를 성공적으로 확인했습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt new file mode 100644 index 00000000..d3842ed1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -0,0 +1,185 @@ +package com.weeth.domain.board.presentation + +import com.weeth.domain.board.application.dto.request.CreatePostRequest +import com.weeth.domain.board.application.dto.request.UpdatePostRequest +import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostLikeActionResponse +import com.weeth.domain.board.application.dto.response.PostListResponse +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.board.application.usecase.command.ManagePostLikeUseCase +import com.weeth.domain.board.application.usecase.command.ManagePostUseCase +import com.weeth.domain.board.application.usecase.command.MarkNoticeReadUseCase +import com.weeth.domain.board.application.usecase.query.GetPostQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.data.domain.Slice +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +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 + +@Tag(name = "BOARD", description = "게시글 API") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/boards") +@ApiErrorCodeExample(BoardErrorCode::class, JwtErrorCode::class) +class PostController( + private val managePostUseCase: ManagePostUseCase, + private val getPostQueryService: GetPostQueryService, + private val markNoticeReadUseCase: MarkNoticeReadUseCase, + private val managePostLikeUseCase: ManagePostLikeUseCase, +) { + @PostMapping("/{boardId}/posts") + @Operation(summary = "게시글 작성") + fun save( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @RequestBody @Valid request: CreatePostRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.POST_CREATED_SUCCESS, + managePostUseCase.save(clubId, boardId, request, userId), + ) + + @GetMapping("/posts") + @Operation(summary = "전체 게시글 조회", description = "클럽 내 접근 가능한 모든 게시판의 게시글을 최신순으로 조회합니다.") + fun findAllPosts( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestParam(defaultValue = "0") pageNumber: Int, + @RequestParam(defaultValue = "10") pageSize: Int, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + BoardResponseCode.POST_FIND_ALL_BY_CLUB_SUCCESS, + getPostQueryService.findAllPosts(clubId, userId, pageNumber, pageSize), + ) + + @GetMapping("/{boardId}/posts") + @Operation(summary = "게시글 목록 조회") + fun findPosts( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @RequestParam(defaultValue = "0") pageNumber: Int, + @RequestParam(defaultValue = "10") pageSize: Int, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + BoardResponseCode.POST_FIND_ALL_SUCCESS, + getPostQueryService.findPosts(clubId, userId, boardId, pageNumber, pageSize), + ) + + @GetMapping("/{boardId}/posts/{postId}") + @Operation(summary = "게시글 상세 조회") + fun findPost( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @PathVariable postId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.POST_FIND_BY_ID_SUCCESS, + getPostQueryService.findPost(clubId, userId, boardId, postId), + ) + + @PatchMapping("/{boardId}/posts/{postId}") + @Operation(summary = "게시글 수정") + fun update( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @PathVariable postId: Long, + @RequestBody @Valid request: UpdatePostRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.POST_UPDATED_SUCCESS, + managePostUseCase.update(clubId, boardId, postId, request, userId), + ) + + @DeleteMapping("/{boardId}/posts/{postId}") + @Operation(summary = "게시글 삭제") + fun delete( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @PathVariable postId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + managePostUseCase.delete(clubId, boardId, postId, userId) + return CommonResponse.success(BoardResponseCode.POST_DELETED_SUCCESS) + } + + @GetMapping("/{boardId}/posts/search") + @Operation(summary = "게시글 검색") + fun searchPosts( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @RequestParam keyword: String, + @RequestParam(defaultValue = "0") pageNumber: Int, + @RequestParam(defaultValue = "10") pageSize: Int, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + BoardResponseCode.POST_SEARCH_SUCCESS, + getPostQueryService.searchPosts(clubId, userId, boardId, keyword, pageNumber, pageSize), + ) + + @PostMapping("/{boardId}/notices/read-all") + @Operation(summary = "공지 읽음 처리", description = "공지 게시판 진입 시 마지막 읽음 시간을 현재 시각으로 갱신합니다.") + fun markAllNoticesRead( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + markNoticeReadUseCase.execute(userId, clubId, boardId) + return CommonResponse.success(BoardResponseCode.BOARD_NOTICE_READ_SUCCESS) + } + + @PostMapping("/{boardId}/posts/{postId}/like") + @Operation(summary = "게시글 좋아요") + fun like( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @PathVariable postId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.POST_LIKE_SUCCESS, + managePostLikeUseCase.like(clubId, boardId, postId, userId), + ) + + @DeleteMapping("/{boardId}/posts/{postId}/like") + @Operation(summary = "게시글 좋아요 취소") + fun unlike( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @PathVariable postId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.POST_UNLIKE_SUCCESS, + managePostLikeUseCase.unlike(clubId, boardId, postId, userId), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalSaveRequest.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalSaveRequest.kt new file mode 100644 index 00000000..13563ead --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalSaveRequest.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.cardinal.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class CardinalSaveRequest( + @field:Schema(description = "기수", example = "4") + val cardinalNumber: Int, + @field:Schema(description = "현재 진행중 여부", example = "false") + val inProgress: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/dto/response/CardinalResponse.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/response/CardinalResponse.kt new file mode 100644 index 00000000..b718a467 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/response/CardinalResponse.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.cardinal.application.dto.response + +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class CardinalResponse( + @field:Schema(description = "기수 ID", example = "1") + val id: Long, + @field:Schema(description = "기수 번호", example = "7") + val cardinalNumber: Int, + @field:Schema(description = "기수 상태", example = "IN_PROGRESS") + val status: CardinalStatus, + @field:Schema(description = "생성 시각") + val createdAt: LocalDateTime?, + @field:Schema(description = "수정 시각") + val modifiedAt: LocalDateTime?, +) diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalErrorCode.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalErrorCode.kt new file mode 100644 index 00000000..3dbcfd80 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalErrorCode.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.cardinal.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class CardinalErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("존재하지 않는 기수 ID 또는 번호로 조회했을 때 발생합니다.") + CARDINAL_NOT_FOUND(21000, HttpStatus.NOT_FOUND, "기수를 찾을 수 없습니다."), + + @ExplainError("이미 존재하는 기수를 생성하려고 할 때 발생합니다.") + DUPLICATE_CARDINAL(21001, HttpStatus.BAD_REQUEST, "이미 존재하는 기수입니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalNotFoundException.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalNotFoundException.kt new file mode 100644 index 00000000..40a77f96 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.cardinal.application.exception + +import com.weeth.global.common.exception.BaseException + +class CardinalNotFoundException : BaseException(CardinalErrorCode.CARDINAL_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/exception/DuplicateCardinalException.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/DuplicateCardinalException.kt new file mode 100644 index 00000000..8d8a8f8a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/DuplicateCardinalException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.cardinal.application.exception + +import com.weeth.global.common.exception.BaseException + +class DuplicateCardinalException : BaseException(CardinalErrorCode.DUPLICATE_CARDINAL) diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt new file mode 100644 index 00000000..8b831a56 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.cardinal.application.mapper + +import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest +import com.weeth.domain.cardinal.application.dto.response.CardinalResponse +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.club.domain.entity.Club +import org.springframework.stereotype.Component + +@Component +class CardinalMapper { + fun toEntity( + club: Club, + request: CardinalSaveRequest, + ): Cardinal = + Cardinal.create( + club = club, + cardinalNumber = request.cardinalNumber, + ) + + fun toResponse(cardinal: Cardinal): CardinalResponse = + CardinalResponse( + cardinal.id, + cardinal.cardinalNumber, + cardinal.status, + cardinal.createdAt, + cardinal.modifiedAt, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt new file mode 100644 index 00000000..cda59d5e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt @@ -0,0 +1,54 @@ +package com.weeth.domain.cardinal.application.usecase.command + +import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.application.exception.DuplicateCardinalException +import com.weeth.domain.cardinal.application.mapper.CardinalMapper +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.cardinal.domain.service.CardinalStatusPolicy +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageCardinalUseCase( + private val cardinalRepository: CardinalRepository, + private val cardinalMapper: CardinalMapper, + private val cardinalStatusPolicy: CardinalStatusPolicy, + private val clubReader: ClubReader, + private val clubPermissionPolicy: ClubPermissionPolicy, +) { + @Transactional + fun save( + clubId: Long, + request: CardinalSaveRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val club = clubReader.getClubById(clubId) + + if (cardinalRepository.findByClubIdAndCardinalNumber(clubId, request.cardinalNumber) != null) { + throw DuplicateCardinalException() + } + + val cardinal = cardinalRepository.save(cardinalMapper.toEntity(club, request)) + + if (request.inProgress) { + cardinalStatusPolicy.activateExclusively(cardinal) + } + } + + @Transactional + fun activate( + clubId: Long, + cardinalId: Long, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val cardinal = + cardinalRepository.findByIdAndClubId(cardinalId, clubId) ?: throw CardinalNotFoundException() + + cardinalStatusPolicy.activateExclusively(cardinal) + } +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt new file mode 100644 index 00000000..82a961c3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt @@ -0,0 +1,25 @@ +package com.weeth.domain.cardinal.application.usecase.query + +import com.weeth.domain.cardinal.application.dto.response.CardinalResponse +import com.weeth.domain.cardinal.application.mapper.CardinalMapper +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetCardinalQueryService( + private val cardinalReader: CardinalReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val cardinalMapper: CardinalMapper, +) { + fun findAll( + clubId: Long, + userId: Long, + ): List { + clubMemberPolicy.getActiveMember(clubId, userId) + + return cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId).map(cardinalMapper::toResponse) + } +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt new file mode 100644 index 00000000..a5e7939a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt @@ -0,0 +1,73 @@ +package com.weeth.domain.cardinal.domain.entity + +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.club.domain.entity.Club +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "cardinal", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_club_id_cardinal_number", + columnNames = ["club_id", "cardinal_number"], + ), + ], +) +class Cardinal( + club: Club, + id: Long = 0L, + @Column(nullable = false) + val cardinalNumber: Int, + status: CardinalStatus = CardinalStatus.DONE, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "cardinal_id") + var id: Long = id + private set + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + var club: Club = club + private set + + @Enumerated(EnumType.STRING) + var status: CardinalStatus = status + private set + + fun inProgress() { + status = CardinalStatus.IN_PROGRESS + } + + fun done() { + status = CardinalStatus.DONE + } + + companion object { + fun create( + club: Club, + cardinalNumber: Int, + status: CardinalStatus = CardinalStatus.DONE, + ): Cardinal { + require(cardinalNumber > 0) { "기수 번호는 0보다 커야 합니다." } + return Cardinal( + club = club, + cardinalNumber = cardinalNumber, + status = status, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/enums/CardinalStatus.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/enums/CardinalStatus.kt new file mode 100644 index 00000000..acbea0f7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/enums/CardinalStatus.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.cardinal.domain.enums + +enum class CardinalStatus { + IN_PROGRESS, + DONE, +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt new file mode 100644 index 00000000..1b942252 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt @@ -0,0 +1,31 @@ +package com.weeth.domain.cardinal.domain.repository + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus + +interface CardinalReader { + fun getByCardinalNumber(cardinalNumber: Int): Cardinal + + fun findByIdOrNull(cardinalId: Long): Cardinal? + + fun findAllByCardinalNumberDesc(): List + + fun findByClubIdAndCardinalNumber( + clubId: Long, + cardinalNumber: Int, + ): Cardinal? + + fun findAllByClubIdAndStatus( + clubId: Long, + status: CardinalStatus, + ): List + + fun findAllByClubIdOrderByCardinalNumberAsc(clubId: Long): List + + fun findInProgressByClubId(clubId: Long): Cardinal? + + fun findAllByClubIdAndIdIn( + clubId: Long, + ids: List, + ): List +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt new file mode 100644 index 00000000..b41fbd41 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt @@ -0,0 +1,61 @@ +package com.weeth.domain.cardinal.domain.repository + +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +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.jpa.repository.QueryHints + +interface CardinalRepository : + JpaRepository, + CardinalReader { + fun findByCardinalNumber(cardinal: Int): Cardinal? + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT c FROM Cardinal c WHERE c.club.id = :clubId AND c.status = 'IN_PROGRESS'") + fun findAllInProgressByClubIdWithLock(clubId: Long): List + + fun findAllByOrderByCardinalNumberDesc(): List + + fun findByIdAndClubId( + id: Long, + clubId: Long, + ): Cardinal? + + override fun findByClubIdAndCardinalNumber( + clubId: Long, + cardinalNumber: Int, + ): Cardinal? + + override fun findAllByClubIdAndStatus( + clubId: Long, + status: CardinalStatus, + ): List + + override fun findAllByClubIdOrderByCardinalNumberAsc(clubId: Long): List + + fun findFirstByClubIdAndStatusOrderByCardinalNumberDesc( + clubId: Long, + status: CardinalStatus, + ): Cardinal? + + override fun findInProgressByClubId(clubId: Long): Cardinal? = + findFirstByClubIdAndStatusOrderByCardinalNumberDesc(clubId, CardinalStatus.IN_PROGRESS) + + override fun getByCardinalNumber(cardinalNumber: Int): Cardinal = + findByCardinalNumber(cardinalNumber) ?: throw CardinalNotFoundException() + + override fun findByIdOrNull(cardinalId: Long): Cardinal? = findById(cardinalId).orElse(null) + + override fun findAllByCardinalNumberDesc(): List = findAllByOrderByCardinalNumberDesc() + + override fun findAllByClubIdAndIdIn( + clubId: Long, + ids: List, + ): List +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt new file mode 100644 index 00000000..5c610e58 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.cardinal.domain.service + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import org.springframework.stereotype.Service + +@Service +class CardinalStatusPolicy( + private val cardinalRepository: CardinalRepository, +) { + fun activateExclusively(cardinal: Cardinal) { + val inProgressCardinals = cardinalRepository.findAllInProgressByClubIdWithLock(cardinal.club.id) + inProgressCardinals.forEach(Cardinal::done) + cardinal.inProgress() + } +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt new file mode 100644 index 00000000..3a889f6f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt @@ -0,0 +1,53 @@ +package com.weeth.domain.cardinal.presentation + +import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest +import com.weeth.domain.cardinal.application.exception.CardinalErrorCode +import com.weeth.domain.cardinal.application.usecase.command.ManageCardinalUseCase +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "CARDINAL ADMIN", description = "[ADMIN] 기수 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/clubs/{clubId}/cardinals") +@ApiErrorCodeExample(CardinalErrorCode::class, JwtErrorCode::class) +class CardinalAdminController( + private val manageCardinalUseCase: ManageCardinalUseCase, +) { + @PatchMapping("/{cardinalId}") + @Operation(summary = "현재 진행 기수 지정 API") + fun activate( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable cardinalId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageCardinalUseCase.activate(clubId, cardinalId, userId) + return CommonResponse.success(CardinalResponseCode.CARDINAL_UPDATE_SUCCESS) + } + + @PostMapping + @Operation(summary = "새로운 기수 정보 저장 API") + fun save( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestBody @Valid request: CardinalSaveRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageCardinalUseCase.save(clubId, request, userId) + return CommonResponse.success(CardinalResponseCode.CARDINAL_SAVE_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt new file mode 100644 index 00000000..4e594e6e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt @@ -0,0 +1,37 @@ +package com.weeth.domain.cardinal.presentation + +import com.weeth.domain.cardinal.application.dto.response.CardinalResponse +import com.weeth.domain.cardinal.application.exception.CardinalErrorCode +import com.weeth.domain.cardinal.application.usecase.query.GetCardinalQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "CARDINAL") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/cardinals") +@ApiErrorCodeExample(CardinalErrorCode::class, JwtErrorCode::class) +class CardinalController( + private val getCardinalQueryService: GetCardinalQueryService, +) { + @GetMapping + @Operation(summary = "현재 저장된 기수 목록 조회 API") + fun findAllCardinals( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + CardinalResponseCode.CARDINAL_FIND_ALL_SUCCESS, + getCardinalQueryService.findAll(clubId, userId), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalResponseCode.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalResponseCode.kt new file mode 100644 index 00000000..6bf3ca2a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalResponseCode.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.cardinal.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class CardinalResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + CARDINAL_FIND_ALL_SUCCESS(11000, HttpStatus.OK, "전체 기수 조회에 성공했습니다."), + CARDINAL_SAVE_SUCCESS(11001, HttpStatus.OK, "기수 저장에 성공했습니다."), + CARDINAL_UPDATE_SUCCESS(11002, HttpStatus.OK, "기수 수정에 성공했습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt new file mode 100644 index 00000000..95ede1a0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt @@ -0,0 +1,41 @@ +package com.weeth.domain.club.application.dto.request + +import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Positive +import jakarta.validation.constraints.Size + +data class ClubCreateRequest( + @field:Schema(description = "동아리 이름", example = "Leets") + @field:NotBlank + @field:Size(max = 100) + val name: String, + @field:Schema(description = "학교 이름", example = "가천대학교") + @field:NotBlank + @field:Size(max = 50) + val schoolName: String, + @field:Schema(description = "동아리 소개", example = "함께 배우고 성장하는 개발자 커뮤니티") + @field:Size(max = 30) + val description: String? = null, + @field:Schema(description = "연락 이메일", example = "club@example.com") + @field:Email + val contactEmail: String? = null, + @field:Schema(description = "연락 전화번호", example = "01012345678") + @field:NotBlank + val contactPhoneNumber: String, + @field:Schema(description = "주 연락처", example = "PHONE") + val primaryContact: PrimaryContact, + @field:Schema(description = "가장 최근 기수 번호", example = "7") + @field:Positive + val currentCardinal: Int, + @field:Schema(description = "프로필 사진") + @field:Valid + val profileImage: FileSaveRequest? = null, + @field:Schema(description = "배경 사진") + @field:Valid + val backgroundImage: FileSaveRequest? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubJoinRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubJoinRequest.kt new file mode 100644 index 00000000..c49c0e05 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubJoinRequest.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.club.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +data class ClubJoinRequest( + @field:Schema(description = "초대 코드", example = "550e8400-e29b-41d4-a716-446655440000") + @field:NotBlank + val code: String, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberApplyObRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberApplyObRequest.kt new file mode 100644 index 00000000..8a9068b6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberApplyObRequest.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.club.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Positive + +data class ClubMemberApplyObRequest( + @field:Schema(description = "대상 멤버 ID", example = "1") + @field:Positive + val clubMemberId: Long, + @field:Schema(description = "적용할 기수", example = "8") + @field:Positive + val cardinal: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberCardinalSetRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberCardinalSetRequest.kt new file mode 100644 index 00000000..41e2e701 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberCardinalSetRequest.kt @@ -0,0 +1,11 @@ +package com.weeth.domain.club.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.Positive + +data class ClubMemberCardinalSetRequest( + @field:Schema(description = "활동 기수 번호 목록", example = "[1, 2, 3]") + @field:NotEmpty + val cardinals: List<@Positive Int>, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt new file mode 100644 index 00000000..ff8be705 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.club.application.dto.request + +import com.weeth.domain.club.domain.enums.MemberRole +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Positive + +data class ClubMemberRoleUpdateRequest( + @field:Schema(description = "변경할 권한 (LEAD는 별도 API로 요청해주세요. 또한 LEAD는 사용자 뷰에 보이지 않게 해주세요)", example = "ADMIN") + val memberRole: MemberRole, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt new file mode 100644 index 00000000..3958e099 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt @@ -0,0 +1,34 @@ +package com.weeth.domain.club.application.dto.request + +import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Size + +data class ClubUpdateRequest( + @field:Schema(description = "동아리 이름 (null=변경 안 함)", example = "Leets") + @field:Size(max = 100) + val name: String? = null, + @field:Schema(description = "학교 이름 (null=변경 안 함)", example = "가천대학교") + @field:Size(max = 50) + val schoolName: String? = null, + @field:Schema(description = "동아리 소개 (null=변경 안 함)", example = "함께 배우고 성장하는 개발자 커뮤니티") + @field:Size(max = 30) + val description: String? = null, + @field:Schema(description = "연락 이메일 (null=변경 안 함)", example = "club@example.com") + @field:Email + val contactEmail: String? = null, + @field:Schema(description = "연락 전화번호 (null=변경 안 함)", example = "01012345678") + @field:Size(min = 1) + val contactPhoneNumber: String? = null, + @field:Schema(description = "주 연락처 (null=변경 안 함)", example = "PHONE") + val primaryContact: PrimaryContact? = null, + @field:Schema(description = "프로필 사진 (null=변경 안 함)") + @field:Valid + val profileImage: FileSaveRequest? = null, + @field:Schema(description = "배경 사진 (null=변경 안 함)") + @field:Valid + val backgroundImage: FileSaveRequest? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberCardinalRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberCardinalRequest.kt new file mode 100644 index 00000000..b167fc1b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberCardinalRequest.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.club.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty + +data class UpdateMemberCardinalRequest( + @field:Schema(description = "기수 ID 목록 (최소 1개)", example = "[1, 2, 3]") + @field:NotEmpty + val cardinalIds: List, + @field:Schema( + description = "출석 기록이 있는 기수 삭제 시 강제 삭제 여부. 서버가 응답코드 21118을 반환하면 true로 재요청", + example = "false", + defaultValue = "false", + ) + val force: Boolean = false, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberProfileRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberProfileRequest.kt new file mode 100644 index 00000000..c299a5d1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberProfileRequest.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.club.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.Size + +data class UpdateMemberProfileRequest( + @field:Schema(description = "프로필 사진") + @field:Valid + val profileImage: FileSaveRequest? = null, + @field:Schema(description = "자기소개", example = "안녕하세요!") + @field:Size(max = 30) + val bio: String? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubCreateResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubCreateResponse.kt new file mode 100644 index 00000000..de1a2c3b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubCreateResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.club.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubCreateResponse( + @field:Schema(description = "동아리 ID (Base62 인코딩)", example = "YUNJcjFKMO") + val clubId: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val clubName: String, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubDetailResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubDetailResponse.kt new file mode 100644 index 00000000..89f31aac --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubDetailResponse.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.club.application.dto.response + +import com.weeth.domain.club.domain.enums.PrimaryContact +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubDetailResponse( + @field:Schema(description = "동아리 ID (Base62 인코딩)", example = "1A2b3C") + val id: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val name: String, + @field:Schema(description = "초대 코드", example = "550e8400-e29b-41d4-a716-446655440000") + val code: String, + @field:Schema(description = "학교 이름", example = "가천대학교") + val schoolName: String, + @field:Schema(description = "동아리 소개", example = "함께 배우고 성장하는 개발자 커뮤니티") + val description: String?, + @field:Schema(description = "연락 이메일", example = "club@example.com") + val contactEmail: String?, + @field:Schema(description = "연락 전화번호", example = "01012345678") + val contactPhoneNumber: String?, + @field:Schema(description = "주 연락처", example = "PHONE") + val primaryContact: PrimaryContact, + @field:Schema(description = "프로필 사진 URL") + val profileImageUrl: String?, + @field:Schema(description = "배경 사진 URL") + val backgroundImageUrl: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubInfoResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubInfoResponse.kt new file mode 100644 index 00000000..e656bf52 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubInfoResponse.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.club.application.dto.response + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubInfoResponse( + @field:Schema(description = "동아리 ID (Base62 인코딩)", example = "1A2b3C") + val id: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val name: String, + @field:Schema(description = "학교 이름", example = "가천대학교") + val schoolName: String, + @field:Schema(description = "동아리 설명", example = "함께 배우고 성장하는 개발자 커뮤니티") + val description: String?, + @field:Schema(description = "동아리 프로필 이미지 URL") + val profileImageUrl: String?, + @field:Schema(description = "활동 부원 수", example = "368") + val memberCount: Long, + @field:Schema(description = "활동 기수 목록", example = "[31, 32]") + val cardinals: List, + @field:Schema(description = "나의 권한", example = "USER") + val memberRole: MemberRole, + @field:Schema(description = "나의 멤버 상태", example = "ACTIVE") + val memberStatus: MemberStatus, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt new file mode 100644 index 00000000..a1dbba12 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt @@ -0,0 +1,37 @@ +package com.weeth.domain.club.application.dto.response + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import io.swagger.v3.oas.annotations.media.Schema + +/** + * 내 멤버 정보 조회 API에 사용 + */ +data class ClubMemberProfileResponse( + @field:Schema(description = "사용자 ID", example = "1") + val userId: Long, + @field:Schema(description = "멤버 ID", example = "1") + val clubMemberId: Long, + @field:Schema(description = "사용자 이름", example = "홍길동") + val name: String, + @field:Schema(description = "이메일", example = "hong@example.com") + val email: String, + @field:Schema(description = "전화번호", example = "01012345678") + val tel: String?, + @field:Schema(description = "학교", example = "가천대학교") + val school: String?, + @field:Schema(description = "학과", example = "컴퓨터공학과") + val department: String?, + @field:Schema(description = "학번", example = "20201234") + val studentId: String?, + @field:Schema(description = "소속 기수 목록", example = "[6, 7]") + val cardinals: List, + @field:Schema(description = "멤버 권한", example = "USER") + val memberRole: MemberRole, + @field:Schema(description = "멤버 상태", example = "ACTIVE") + val memberStatus: MemberStatus, + @field:Schema(description = "동아리 프로필 이미지 URL", example = "https://cdn.example.com/profile.jpg") + val profileImageUrl: String?, + @field:Schema(description = "자기소개") + val bio: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt new file mode 100644 index 00000000..a6ebca8b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt @@ -0,0 +1,41 @@ +package com.weeth.domain.club.application.dto.response + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import io.swagger.v3.oas.annotations.media.Schema + +/** + * 동아리 멤버 목록 조회(관리자용) API에 사용 + */ +data class ClubMemberResponse( + @field:Schema(description = "사용자 ID", example = "1") + val userId: Long, + @field:Schema(description = "멤버 ID", example = "1") + val clubMemberId: Long, + @field:Schema(description = "사용자 이름", example = "홍길동") + val name: String, + @field:Schema(description = "이메일", example = "hong@example.com") + val email: String, + @field:Schema(description = "전화번호", example = "01012345678") + val tel: String?, + @field:Schema(description = "학교", example = "가천대학교") + val school: String?, + @field:Schema(description = "학과", example = "컴퓨터공학과") + val department: String?, + @field:Schema(description = "학번", example = "20201234") + val studentId: String?, + @field:Schema(description = "소속 기수 목록", example = "[6, 7]") + val cardinals: List, + @field:Schema(description = "멤버 상태", example = "ACTIVE") + val memberStatus: MemberStatus, + @field:Schema(description = "멤버 권한", example = "USER") + val memberRole: MemberRole, + @field:Schema(description = "출석 횟수", example = "10") + val attendanceCount: Int, + @field:Schema(description = "결석 횟수", example = "2") + val absenceCount: Int, + @field:Schema(description = "출석률 (%)", example = "83") + val attendanceRate: Int, + @field:Schema(description = "패널티 횟수", example = "1") + val penaltyCount: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberSummaryResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberSummaryResponse.kt new file mode 100644 index 00000000..b17ee4b8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberSummaryResponse.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.club.application.dto.response + +import com.weeth.domain.club.domain.enums.MemberRole +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubMemberSummaryResponse( + @field:Schema(description = "사용자 ID", example = "1") + val userId: Long, + @field:Schema(description = "이름", example = "홍길동") + val name: String, + @field:Schema(description = "소속 기수 목록", example = "[6, 7]") + val cardinals: List, + @field:Schema(description = "동아리 내 권한", example = "USER") + val role: MemberRole, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMembershipStatusResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMembershipStatusResponse.kt new file mode 100644 index 00000000..3f878f48 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMembershipStatusResponse.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.club.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubMembershipStatusResponse( + @field:Schema(description = "ACTIVE 상태 동아리 존재 여부", example = "true") + val hasActiveClub: Boolean, + @field:Schema(description = "WAITING 상태 동아리 존재 여부", example = "false") + val hasWaitingClub: Boolean, +// @field:Schema(description = "BANNED 상태 동아리 존재 여부", example = "false") 추후 추가 +// val hasBannedClub: Boolean, + @field:Schema(description = "ACTIVE 동아리 정보 (없으면 null)") + val activeClub: ClubInfoResponse?, + @field:Schema(description = "WAITING 동아리 정보 (없으면 null)") + val waitingClub: ClubInfoResponse?, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubPublicResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubPublicResponse.kt new file mode 100644 index 00000000..c7ffbe53 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubPublicResponse.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.club.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubPublicResponse( + @field:Schema(description = "동아리 ID (Base62 인코딩)", example = "1A2b3C") + val id: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val name: String, + @field:Schema(description = "동아리 소개", example = "함께 배우고 성장하는 개발자 커뮤니티") + val description: String?, + @field:Schema(description = "프로필 사진 URL") + val profileImageUrl: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ProfileStatusResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ProfileStatusResponse.kt new file mode 100644 index 00000000..6eb90465 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ProfileStatusResponse.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.club.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ProfileStatusResponse( + @field:Schema(description = "기수 등록 여부") + val cardinalAssigned: Boolean, + @field:Schema(description = "프로필 완성 여부 (이름, 학번, 전화번호, 학교, 학과)") + val profileCompleted: Boolean, + @field:Schema(description = "미완성 필드 목록", example = "[\"studentId\", \"tel\"]") + val missingFields: List, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/AlreadyJoinedException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/AlreadyJoinedException.kt new file mode 100644 index 00000000..13756229 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/AlreadyJoinedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class AlreadyJoinedException : BaseException(ClubErrorCode.ALREADY_JOINED) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/CannotBanLeadException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/CannotBanLeadException.kt new file mode 100644 index 00000000..f32dc0b4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/CannotBanLeadException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class CannotBanLeadException : BaseException(ClubErrorCode.CANNOT_BAN_LEAD) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsLeadException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsLeadException.kt new file mode 100644 index 00000000..316ebe87 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsLeadException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class CannotLeaveAsLeadException : BaseException(ClubErrorCode.CANNOT_LEAVE_AS_LEAD) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalAlreadySetException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalAlreadySetException.kt new file mode 100644 index 00000000..ca425c30 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalAlreadySetException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class CardinalAlreadySetException : BaseException(ClubErrorCode.CARDINAL_ALREADY_SET) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalRemovalHasAttendanceException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalRemovalHasAttendanceException.kt new file mode 100644 index 00000000..22b098f7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalRemovalHasAttendanceException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class CardinalRemovalHasAttendanceException : BaseException(ClubErrorCode.CARDINAL_REMOVAL_HAS_ATTENDANCE) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubCreateLimitExceededException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubCreateLimitExceededException.kt new file mode 100644 index 00000000..423276ab --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubCreateLimitExceededException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class ClubCreateLimitExceededException : BaseException(ClubErrorCode.CLUB_CREATE_LIMIT_EXCEEDED) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt new file mode 100644 index 00000000..fe94effd --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt @@ -0,0 +1,75 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class ClubErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("동아리 ID로 조회했으나 존재하지 않을 때 발생합니다.") + CLUB_NOT_FOUND(21100, HttpStatus.NOT_FOUND, "존재하지 않는 동아리입니다."), + + @ExplainError("가입 신청 시 초대 코드가 일치하지 않을 때 발생합니다.") + INVALID_CLUB_CODE(21101, HttpStatus.BAD_REQUEST, "유효하지 않은 초대 코드입니다."), + + @ExplainError("이미 가입한 동아리에 재가입 시도할 때 발생합니다.") + ALREADY_JOINED(21102, HttpStatus.CONFLICT, "이미 가입된 동아리입니다."), + + @ExplainError("동아리 멤버가 아닌 사용자가 동아리 리소스에 접근할 때 발생합니다.") + CLUB_MEMBER_NOT_FOUND(21103, HttpStatus.NOT_FOUND, "동아리 멤버가 아닙니다."), + + @ExplainError("동아리 관리자 권한이 필요한 작업을 일반 멤버가 시도할 때 발생합니다.") + NOT_CLUB_ADMIN(21104, HttpStatus.FORBIDDEN, "동아리 관리자 권한이 필요합니다."), + + @ExplainError("리더가 권한 이양 없이 동아리를 탈퇴하려 할 때 발생합니다.") + CANNOT_LEAVE_AS_LEAD(21105, HttpStatus.BAD_REQUEST, "리더는 권한 이양 후 탈퇴할 수 있습니다."), + + @ExplainError("비활성 멤버가 동아리 리소스에 접근할 때 발생합니다.") + MEMBER_NOT_ACTIVE(21106, HttpStatus.FORBIDDEN, "비활성 멤버입니다."), + + @ExplainError("리더를 권한 이양 없이 추방하려 할 때 발생합니다.") + CANNOT_BAN_LEAD(21107, HttpStatus.BAD_REQUEST, "리더는 권한 이양 후 추방할 수 있습니다."), + + @ExplainError("요청한 멤버가 해당 동아리에 속하지 않을 때 발생합니다.") + CLUB_MEMBER_NOT_IN_CLUB(21108, HttpStatus.BAD_REQUEST, "해당 동아리에 속한 멤버가 아닙니다."), + + @ExplainError("이미 활동 기수가 설정된 멤버가 다시 설정을 시도할 때 발생합니다.") + CARDINAL_ALREADY_SET(21109, HttpStatus.CONFLICT, "이미 활동 기수가 설정되어 있습니다."), + + @ExplainError("일반 멤버(USER)로 가입 가능한 동아리 수(최대 1개)를 초과했을 때 발생합니다.") + CLUB_JOIN_LIMIT_EXCEEDED(21110, HttpStatus.CONFLICT, "가입 가능한 동아리 수를 초과했습니다."), + + @ExplainError("동아리장(LEAD)으로 생성 가능한 동아리 수(최대 1개)를 초과했을 때 발생합니다.") + CLUB_CREATE_LIMIT_EXCEEDED(21111, HttpStatus.CONFLICT, "생성 가능한 동아리 수를 초과했습니다."), + + @ExplainError("주 연락처를 이메일로 설정했으나 이메일이 입력되지 않았을 때 발생합니다.") + EMAIL_REQUIRED_FOR_PRIMARY_CONTACT(21112, HttpStatus.BAD_REQUEST, "주 연락처를 이메일로 설정하려면 이메일을 입력해야 합니다."), + + @ExplainError("LEAD가 아닌 멤버가 LEAD 이양을 시도할 때 발생합니다.") + NOT_LEAD(21113, HttpStatus.FORBIDDEN, "LEAD만 권한을 이양할 수 있습니다."), + + @ExplainError("LEAD를 이양이 아닌 직접 역할 변경으로 설정하려 할 때 발생합니다.") + LEAD_TRANSFER_ONLY(21114, HttpStatus.BAD_REQUEST, "LEAD는 이양을 통해서만 변경할 수 있습니다."), + + @ExplainError("자기 자신에게 LEAD 권한을 이양하려 할 때 발생합니다.") + LEAD_SELF_TRANSFER(21115, HttpStatus.BAD_REQUEST, "자기 자신에게 LEAD를 이양할 수 없습니다."), + + @ExplainError("관리자가 자기 자신을 추방하려 할 때 발생합니다.") + SELF_BAN_NOT_ALLOWED(21116, HttpStatus.BAD_REQUEST, "자기 자신은 추방할 수 없습니다."), + + @ExplainError("관리자가 자기 자신의 권한을 변경하려 할 때 발생합니다.") + SELF_ROLE_CHANGE_NOT_ALLOWED(21117, HttpStatus.BAD_REQUEST, "자기 자신의 권한은 변경할 수 없습니다."), + + @ExplainError("삭제하려는 기수에 출석/결석 기록이 존재할 때 발생합니다. force=true로 재요청하면 출석 기록을 포함해 삭제됩니다.") + CARDINAL_REMOVAL_HAS_ATTENDANCE( + 21118, + HttpStatus.UNPROCESSABLE_ENTITY, + "출석 기록이 있는 기수가 포함되어 있습니다. 삭제하려면 force=true로 재요청하세요.", + ), + + @ExplainError("생성하려는 동아리가 이미 있는 경우 발생합니다. 동아리 중복은 동일한 학교 안에 동일한 이름의 동아리가 있는지 검증합니다.") + DUPLICATE_CLUB(21119, HttpStatus.CONFLICT, "이미 존재하는 동아리입니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubJoinLimitExceededException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubJoinLimitExceededException.kt new file mode 100644 index 00000000..dead730c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubJoinLimitExceededException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class ClubJoinLimitExceededException : BaseException(ClubErrorCode.CLUB_JOIN_LIMIT_EXCEEDED) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotFoundException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotFoundException.kt new file mode 100644 index 00000000..0152a799 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class ClubMemberNotFoundException : BaseException(ClubErrorCode.CLUB_MEMBER_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotInClubException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotInClubException.kt new file mode 100644 index 00000000..bc239b40 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotInClubException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class ClubMemberNotInClubException : BaseException(ClubErrorCode.CLUB_MEMBER_NOT_IN_CLUB) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubNotFoundException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubNotFoundException.kt new file mode 100644 index 00000000..31192dc1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class ClubNotFoundException : BaseException(ClubErrorCode.CLUB_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/DuplicateClubException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/DuplicateClubException.kt new file mode 100644 index 00000000..53b64053 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/DuplicateClubException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class DuplicateClubException : BaseException(ClubErrorCode.DUPLICATE_CLUB) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/EmailRequiredForPrimaryContactException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/EmailRequiredForPrimaryContactException.kt new file mode 100644 index 00000000..508e6cbf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/EmailRequiredForPrimaryContactException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class EmailRequiredForPrimaryContactException : BaseException(ClubErrorCode.EMAIL_REQUIRED_FOR_PRIMARY_CONTACT) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/InvalidClubCodeException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/InvalidClubCodeException.kt new file mode 100644 index 00000000..cbda5c74 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/InvalidClubCodeException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class InvalidClubCodeException : BaseException(ClubErrorCode.INVALID_CLUB_CODE) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/LeadSelfTransferException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/LeadSelfTransferException.kt new file mode 100644 index 00000000..677f9f50 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/LeadSelfTransferException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class LeadSelfTransferException : BaseException(ClubErrorCode.LEAD_SELF_TRANSFER) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/LeadTransferOnlyException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/LeadTransferOnlyException.kt new file mode 100644 index 00000000..714c2553 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/LeadTransferOnlyException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class LeadTransferOnlyException : BaseException(ClubErrorCode.LEAD_TRANSFER_ONLY) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/MemberNotActiveException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/MemberNotActiveException.kt new file mode 100644 index 00000000..2341175d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/MemberNotActiveException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class MemberNotActiveException : BaseException(ClubErrorCode.MEMBER_NOT_ACTIVE) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/NotClubAdminException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/NotClubAdminException.kt new file mode 100644 index 00000000..682c05af --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/NotClubAdminException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class NotClubAdminException : BaseException(ClubErrorCode.NOT_CLUB_ADMIN) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/NotLeadException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/NotLeadException.kt new file mode 100644 index 00000000..3ff50699 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/NotLeadException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class NotLeadException : BaseException(ClubErrorCode.NOT_LEAD) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/SelfBanNotAllowedException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/SelfBanNotAllowedException.kt new file mode 100644 index 00000000..17d523e3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/SelfBanNotAllowedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class SelfBanNotAllowedException : BaseException(ClubErrorCode.SELF_BAN_NOT_ALLOWED) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/SelfRoleChangeNotAllowedException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/SelfRoleChangeNotAllowedException.kt new file mode 100644 index 00000000..76077ac2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/SelfRoleChangeNotAllowedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class SelfRoleChangeNotAllowedException : BaseException(ClubErrorCode.SELF_ROLE_CHANGE_NOT_ALLOWED) diff --git a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt new file mode 100644 index 00000000..19b4a975 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt @@ -0,0 +1,174 @@ +package com.weeth.domain.club.application.mapper + +import com.weeth.domain.club.application.dto.response.ClubCreateResponse +import com.weeth.domain.club.application.dto.response.ClubDetailResponse +import com.weeth.domain.club.application.dto.response.ClubInfoResponse +import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse +import com.weeth.domain.club.application.dto.response.ClubMemberResponse +import com.weeth.domain.club.application.dto.response.ClubMemberSummaryResponse +import com.weeth.domain.club.application.dto.response.ClubMembershipStatusResponse +import com.weeth.domain.club.application.dto.response.ClubPublicResponse +import com.weeth.domain.club.application.dto.response.ProfileStatusResponse +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.id.TsidBase62Encoder +import org.springframework.stereotype.Component + +@Component +class ClubMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { + fun toInfoResponse( + club: Club, + member: ClubMember, + cardinals: List, + memberCount: Long, + ) = ClubInfoResponse( + id = TsidBase62Encoder.encode(club.id), + name = club.name, + schoolName = club.schoolName, + description = club.description, + profileImageUrl = resolveClubImage(club.profileImageStorageKey), + memberCount = memberCount, + cardinals = toCardinalNumbers(cardinals), + memberRole = member.memberRole, + memberStatus = member.memberStatus, + ) + + fun toResponse(club: Club) = + ClubPublicResponse( + id = TsidBase62Encoder.encode(club.id), + name = club.name, + description = club.description, + profileImageUrl = resolveClubImage(club.profileImageStorageKey), + ) + + fun toDetailResponse(club: Club) = + ClubDetailResponse( + id = TsidBase62Encoder.encode(club.id), + name = club.name, + code = club.code, + schoolName = club.schoolName, + description = club.description, + contactEmail = club.clubContact.email, + contactPhoneNumber = club.clubContact.phoneNumber, + primaryContact = club.clubContact.primaryContact, + profileImageUrl = resolveClubImage(club.profileImageStorageKey), + backgroundImageUrl = resolveClubImage(club.backgroundImageStorageKey), + ) + + fun toMemberResponse( + member: ClubMember, + cardinals: List, + ) = ClubMemberResponse( + userId = member.user.id, + clubMemberId = member.id, + name = member.user.name, + email = member.user.emailValue, + tel = member.user.telValue, + school = member.user.school, + department = member.user.department, + studentId = member.user.studentId, + cardinals = toCardinalNumbers(cardinals), + memberStatus = member.memberStatus, + memberRole = member.memberRole, + attendanceCount = member.attendanceStats.attendanceCount, + absenceCount = member.attendanceStats.absenceCount, + attendanceRate = member.attendanceStats.attendanceRate, + penaltyCount = member.penaltyCount, + ) + + fun toMemberProfileResponse( + member: ClubMember, + cardinals: List, + ) = ClubMemberProfileResponse( + userId = member.user.id, + clubMemberId = member.id, + name = member.user.name, + email = member.user.emailValue, + tel = member.user.telValue, + school = member.user.school, + department = member.user.department, + studentId = member.user.studentId, + cardinals = toCardinalNumbers(cardinals), + memberRole = member.memberRole, + memberStatus = member.memberStatus, + profileImageUrl = member.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, + bio = member.bio, + ) + + fun toMemberSummaryResponse( + member: ClubMember, + cardinals: List, + ) = ClubMemberSummaryResponse( + userId = member.user.id, + name = member.user.name, + cardinals = toCardinalNumbers(cardinals), + role = member.memberRole, + ) + + fun toMembershipStatusResponse( + members: List, + cardinalsByMemberId: Map>, + memberCountByClubId: Map, + ): ClubMembershipStatusResponse { + val activeMember = members.firstOrNull { it.memberStatus == MemberStatus.ACTIVE } + val waitingMember = members.firstOrNull { it.memberStatus == MemberStatus.WAITING } + val bannedMember = members.firstOrNull { it.memberStatus == MemberStatus.BANNED } + + return ClubMembershipStatusResponse( + hasActiveClub = activeMember != null, + hasWaitingClub = waitingMember != null, +// hasBannedClub = bannedMember != null, 추후 추가 + activeClub = + activeMember?.let { + toInfoResponse( + it.club, + it, + cardinalsByMemberId[it.id] ?: emptyList(), + memberCountByClubId[it.club.id] ?: 0, + ) + }, + waitingClub = + waitingMember?.let { + toInfoResponse( + it.club, + it, + cardinalsByMemberId[it.id] ?: emptyList(), + memberCountByClubId[it.club.id] ?: 0, + ) + }, + ) + } + + fun toProfileStatusResponse( + user: User, + cardinalAssigned: Boolean, + ) = ProfileStatusResponse( + profileCompleted = user.isProfileCompleted(), + cardinalAssigned = cardinalAssigned, + missingFields = user.missingProfileFields(), + ) + + fun toCreateResponse(club: Club) = + ClubCreateResponse( + clubId = TsidBase62Encoder.encode(club.id), + clubName = club.name, + ) + + private fun resolveClubImage(storageKey: String?): String? = storageKey?.let { fileAccessUrlPort.resolve(it) } + + private fun toCardinalNumbers(cardinals: List): List { + if (cardinals.isEmpty()) { + return emptyList() + } + + return cardinals + .map { it.cardinal.cardinalNumber } + .sorted() + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt new file mode 100644 index 00000000..c56a5961 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt @@ -0,0 +1,253 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest +import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberCardinalRequest +import com.weeth.domain.club.application.exception.CannotBanLeadException +import com.weeth.domain.club.application.exception.CardinalRemovalHasAttendanceException +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException +import com.weeth.domain.club.application.exception.ClubMemberNotInClubException +import com.weeth.domain.club.application.exception.LeadSelfTransferException +import com.weeth.domain.club.application.exception.LeadTransferOnlyException +import com.weeth.domain.club.application.exception.NotLeadException +import com.weeth.domain.club.application.exception.SelfBanNotAllowedException +import com.weeth.domain.club.application.exception.SelfRoleChangeNotAllowedException +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.penalty.domain.repository.PenaltyReader +import com.weeth.domain.session.domain.repository.SessionReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 동아리 관리자 전용 멤버 관리 UseCase + */ +@Service +class AdminClubMemberUseCase( + private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, + private val cardinalReader: CardinalReader, + private val clubMemberReader: ClubMemberReader, + private val sessionReader: SessionReader, + private val attendanceRepository: AttendanceRepository, + private val penaltyReader: PenaltyReader, + private val clubMemberCardinalRepository: ClubMemberCardinalRepository, +) { + @Transactional + fun accept( + clubId: Long, + userId: Long, + clubMemberId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val member = clubMemberPolicy.getMemberInClub(clubId, clubMemberId) + member.accept() + } + + @Transactional + fun ban( + clubId: Long, + userId: Long, + clubMemberId: Long, + ) { + val adminMember = clubPermissionPolicy.requireAdmin(clubId, userId) + + val member = clubMemberPolicy.getActiveMemberInClubWithLock(clubId, clubMemberId) + if (adminMember.id == member.id) throw SelfBanNotAllowedException() + if (member.isLead()) throw CannotBanLeadException() + member.ban() + } + + @Transactional + fun restore( + clubId: Long, + userId: Long, + clubMemberId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val member = clubMemberPolicy.getMemberInClub(clubId, clubMemberId) + member.restore() + } + + @Transactional + fun updateMemberRole( + clubId: Long, + userId: Long, + clubMemberId: Long, + request: ClubMemberRoleUpdateRequest, + ) { + val adminMember = clubPermissionPolicy.requireAdmin(clubId, userId) + + val member = clubMemberPolicy.getMemberInClub(clubId, clubMemberId) + if (request.memberRole == MemberRole.LEAD) throw LeadTransferOnlyException() + if (adminMember.id == member.id) throw SelfRoleChangeNotAllowedException() + if (member.isLead()) throw LeadTransferOnlyException() + member.updateRole(request.memberRole) + } + + @Transactional + fun transferLead( + clubId: Long, + userId: Long, + targetClubMemberId: Long, + ) { + val currentLead = clubMemberPolicy.getActiveMemberWithLock(clubId, userId) + if (!currentLead.isLead()) throw NotLeadException() + + val target = clubMemberPolicy.getActiveMemberInClubWithLock(clubId, targetClubMemberId) + if (currentLead.id == target.id) throw LeadSelfTransferException() + + currentLead.releaseLead() + target.assignLead() + } + + // TODO: setInitialCardinals와 동시 호출 시 출석 중복 생성 가능 — 멤버 단위 락 추가 검토 + @Transactional + fun applyOb( + clubId: Long, + userId: Long, + requests: List, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val uniqueRequests = requests.distinctBy { it.clubMemberId to it.cardinal } + if (uniqueRequests.isEmpty()) return + + val memberIds = uniqueRequests.map { it.clubMemberId }.distinct().sorted() + val memberMap = + clubMemberReader + .findAllByIdsWithLock(memberIds) + .also { members -> + if (members.any { it.club.id != clubId }) throw ClubMemberNotInClubException() + }.associateBy { it.id } + + val cardinalByNumber = mutableMapOf() + val attendanceInitMap = linkedMapOf>() + + uniqueRequests.forEach { request -> + val member = memberMap[request.clubMemberId] ?: throw ClubMemberNotFoundException() + val nextCardinal = + cardinalByNumber.getOrPut(request.cardinal) { + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) + ?: throw CardinalNotFoundException() + } + + if (clubMemberCardinalPolicy.notContains(member, nextCardinal)) { + if (clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, nextCardinal)) { + member.resetAttendanceStats() + member.resetPenaltyCount() + attendanceInitMap.getOrPut(member) { mutableListOf() }.add(nextCardinal) + } + + clubMemberCardinalRepository.save(ClubMemberCardinal.create(member, nextCardinal)) + } + } + + attendanceInitMap.forEach { (member, cardinals) -> + initializeAttendances(clubId, member, cardinals) + } + } + + @Transactional + fun updateCardinals( + clubId: Long, + userId: Long, + clubMemberId: Long, + request: UpdateMemberCardinalRequest, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val member = + clubMemberReader.findByIdWithLock(clubMemberId) + ?: throw ClubMemberNotFoundException() + if (member.club.id != clubId) throw ClubMemberNotInClubException() + + val distinctIds = request.cardinalIds.distinct() + val newCardinals = cardinalReader.findAllByClubIdAndIdIn(clubId, distinctIds) + if (newCardinals.size != distinctIds.size) throw CardinalNotFoundException() + + val currentMemberCardinals = clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) + val existingCardinalIds = currentMemberCardinals.map { it.cardinal.id }.toSet() + val newCardinalIds = newCardinals.map { it.id }.toSet() + + val toRemove = currentMemberCardinals.filter { it.cardinal.id !in newCardinalIds } + if (toRemove.isNotEmpty()) { + val removedCardinalNumbers = toRemove.map { it.cardinal.cardinalNumber } + val sessions = sessionReader.findAllByClubIdAndCardinalIn(clubId, removedCardinalNumbers) + if (sessions.isNotEmpty()) { + val attendances = attendanceRepository.findAllByClubMemberAndSessionIn(member, sessions) + + // force=true면 강제 삭제, 아니면 클라이언트에 확인 요청 + val hasRecord = + attendances.any { + it.status == AttendanceStatus.ATTEND || it.status == AttendanceStatus.ABSENT + } + if (!request.force && hasRecord) { + throw CardinalRemovalHasAttendanceException() + } + + attendanceRepository.deleteAll(attendances) + } + + val latestCardinal = newCardinals.maxByOrNull { it.cardinalNumber } + if (latestCardinal == null) { + member.recalculateAttendanceStats(0, 0) + member.recalculatePenaltyCount(0) + } else if (latestCardinal.id in existingCardinalIds) { + // 기존 기수가 최신 — 해당 기수 출석 기준으로 재계산 + val remaining = + attendanceRepository.findAllByClubMemberIdAndCardinal( + member.id, + latestCardinal.cardinalNumber, + ) + member.recalculateAttendanceStats( + remaining.count { it.status == AttendanceStatus.ATTEND }, + remaining.count { it.status == AttendanceStatus.ABSENT }, + ) + val penaltyCount = penaltyReader.countByClubMemberIdAndCardinalId(member.id, latestCardinal.id) + member.recalculatePenaltyCount(penaltyCount) + // else: 최신 기수가 toAdd 소속 → toAdd 블록에서 reset 처리 + } + + clubMemberCardinalRepository.deleteAll(toRemove) + } + + val toAdd = newCardinals.filter { it.id !in existingCardinalIds } + if (toAdd.isNotEmpty()) { + val maxAdded = toAdd.maxBy { it.cardinalNumber } + if (clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, maxAdded)) { + member.resetAttendanceStats() + member.resetPenaltyCount() + } + clubMemberCardinalRepository.saveAll(toAdd.map { ClubMemberCardinal.create(member, it) }) + initializeAttendances(clubId, member, toAdd) + } + } + + // TODO: ManageClubMemberUsecase.initializeAttendances와 중복 — MVP 후 공통 서비스로 추출 + private fun initializeAttendances( + clubId: Long, + member: ClubMember, + cardinals: List, + ) { + val sessions = sessionReader.findAllByClubIdAndCardinalIn(clubId, cardinals.map { it.cardinalNumber }) + if (sessions.isEmpty()) return + + attendanceRepository.saveAll(sessions.map { Attendance.create(session = it, clubMember = member) }) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt new file mode 100644 index 00000000..9b9ce1e2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -0,0 +1,197 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.application.dto.request.ClubJoinRequest +import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberProfileRequest +import com.weeth.domain.club.application.exception.AlreadyJoinedException +import com.weeth.domain.club.application.exception.CannotLeaveAsLeadException +import com.weeth.domain.club.application.exception.CardinalAlreadySetException +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.domain.service.ClubCodePolicy +import com.weeth.domain.club.domain.service.ClubJoinPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 동아리 가입, 탈퇴 UseCase. + */ +@Service +class ManageClubMemberUsecase( + private val clubRepository: ClubRepository, + private val clubMemberRepository: ClubMemberRepository, + private val clubMemberCardinalRepository: ClubMemberCardinalRepository, + private val cardinalReader: CardinalReader, + private val sessionReader: SessionReader, + private val attendanceRepository: AttendanceRepository, + private val userReader: UserReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubJoinPolicy: ClubJoinPolicy, + private val fileRepository: FileRepository, + private val fileAccessUrlPort: FileAccessUrlPort, +) { + /** + * 초대 코드가 일치하면 자동으로 활성 상태로 가입됨 + * 역할(LEAD/USER)별 가입 제한 정책 적용 + * 출석 초기화는 setInitialCardinals() 호출 시 처리됨 + */ + @Transactional + fun join( + clubId: Long, + userId: Long, + request: ClubJoinRequest, + ) { + val club = clubRepository.getClubById(clubId) + val user = + userReader.getByIdWithLock(userId) + + clubMemberRepository.findByClubIdAndUserId(clubId, userId)?.let { + throw AlreadyJoinedException() + } + + clubJoinPolicy.validateJoinLimit(userId) + + ClubCodePolicy.validate(club.code, request.code) + + val member = + ClubMember + .create( + club = club, + user = user, + memberRole = MemberRole.USER, + ).apply { + accept() + } + + clubMemberRepository.save(member) + } + + @Transactional + fun updateProfile( + userId: Long, + request: UpdateMemberProfileRequest, + ) { + val members = clubMemberRepository.findActiveByUserId(userId) + if (members.isEmpty()) throw ClubMemberNotFoundException() + + request.profileImage?.let { profileImage -> + val existingFiles = + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_MEMBER_PROFILE, + userId, + FileStatus.UPLOADED, + ) + if (existingFiles.isNotEmpty()) { + fileRepository.deleteAll(existingFiles) + } + + val file = + File.createUploaded( + fileName = profileImage.fileName, + storageKey = profileImage.storageKey, + fileSize = profileImage.fileSize, + contentType = profileImage.contentType, + ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, + ownerId = userId, + ) + fileRepository.save(file) + + members.forEach { it.updateProfileImageUrl(file.storageKey.value) } + } + + request.bio?.let { bio -> members.forEach { it.updateBio(bio) } } + } + + @Transactional + fun deleteProfileImage(userId: Long) { + val members = clubMemberRepository.findActiveByUserId(userId) + if (members.isEmpty()) throw ClubMemberNotFoundException() + + val existingFiles = + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_MEMBER_PROFILE, + userId, + FileStatus.UPLOADED, + ) + if (existingFiles.isNotEmpty()) { + fileRepository.deleteAll(existingFiles) + } + + members.forEach { it.removeProfileImage() } + } + + /** + * 활동 기수를 최초 1회 설정 + * 이미 설정된 경우 CardinalAlreadySetException 발생 + */ + @Transactional + fun setInitialCardinals( + clubId: Long, + userId: Long, + request: ClubMemberCardinalSetRequest, + ) { + val member = clubMemberPolicy.getActiveMemberWithLock(clubId, userId) + + if (clubMemberCardinalRepository.existsByClubMember(member)) { + throw CardinalAlreadySetException() + } + + val cardinals = + request.cardinals.distinct().map { number -> + cardinalReader.findByClubIdAndCardinalNumber(clubId, number) + ?: throw CardinalNotFoundException() + } + + clubMemberCardinalRepository.saveAll(cardinals.map { ClubMemberCardinal.create(member, it) }) + + initializeAttendances(clubId, member, cardinals) + } + + // TODO: AdminClubMemberUseCase.initializeAttendances와 중복 — MVP 후 공통 서비스로 추출 + private fun initializeAttendances( + clubId: Long, + member: ClubMember, + cardinals: List, + ) { + val sessions = sessionReader.findAllByClubIdAndCardinalIn(clubId, cardinals.map { it.cardinalNumber }) + if (sessions.isEmpty()) return + + val attendances = sessions.map { Attendance.create(session = it, clubMember = member) } + attendanceRepository.saveAll(attendances) + } + + /** + * LEAD 권한을 가진 멤버는 탈퇴 불가 + */ + @Transactional + fun leave( + clubId: Long, + userId: Long, + ) { + val member = clubMemberPolicy.getActiveMemberWithLock(clubId, userId) + + if (member.memberRole == MemberRole.LEAD) { + throw CannotLeaveAsLeadException() + } + + member.leave() + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt new file mode 100644 index 00000000..886a263a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -0,0 +1,264 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.club.application.dto.request.ClubCreateRequest +import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.application.dto.response.ClubCreateResponse +import com.weeth.domain.club.application.exception.DuplicateClubException +import com.weeth.domain.club.application.exception.EmailRequiredForPrimaryContactException +import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.domain.service.ClubCodePolicy +import com.weeth.domain.club.domain.service.ClubJoinPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 동아리 관리 유스케이스 + * 생성은 누구나 가능하지만 그 외 작업은 관리자만 가능 + */ +@Service +class ManageClubUseCase( + private val clubRepository: ClubRepository, + private val clubMemberRepository: ClubMemberRepository, + private val cardinalRepository: CardinalRepository, + private val clubMemberCardinalRepository: ClubMemberCardinalRepository, + private val boardRepository: BoardRepository, + private val userReader: UserReader, + private val clubJoinPolicy: ClubJoinPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val fileRepository: FileRepository, + private val clubMapper: ClubMapper, +) { + /** + * 새로운 동아리를 생성 + * 생성자는 자동으로 LEAD 권한 설정 + * 1기부터 currentCardinal기까지 Cardinal을 자동 생성하고, LEAD를 최신 기수에 배정 + */ + @Transactional + fun create( + userId: Long, + request: ClubCreateRequest, + ): ClubCreateResponse { + validatePrimaryContactEmail(request.primaryContact, request.contactEmail) + checkDuplicateClubName(request.schoolName, request.name) + + val user = + userReader.getByIdWithLock(userId) + clubJoinPolicy.validateCreateLimit(userId) + + val code = ClubCodePolicy.generateCode() + val clubContact = + ClubContact.from( + email = request.contactEmail, + phoneNumber = request.contactPhoneNumber, + primaryContact = request.primaryContact, + ) + + val club = + Club.create( + name = request.name, + code = code, + schoolName = request.schoolName, + clubContact = clubContact, + description = request.description, + profileImageStorageKey = request.profileImage?.storageKey, + backgroundImageStorageKey = request.backgroundImage?.storageKey, + ) + + clubRepository.save(club) + + saveFileIfPresent(request.profileImage, FileOwnerType.CLUB_PROFILE, club.id) + saveFileIfPresent(request.backgroundImage, FileOwnerType.CLUB_BACKGROUND, club.id) + + // 공지사항 게시판 자동 생성 (관리자만 작성 가능, displayOrder=0) + val noticeBoard = + Board( + club = club, + name = "공지사항", + description = "운영진이 공지사항을 올리는 게시판입니다.", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = MemberRole.ADMIN), + ) + boardRepository.save(noticeBoard) + + val leadMember = + ClubMember + .create( + club = club, + user = user, + memberRole = MemberRole.LEAD, + ).apply { + accept() + } + + clubMemberRepository.save(leadMember) + + // 1기 - currentCardinal기까지 Cardinal 자동 생성 + val cardinals = + (1..request.currentCardinal).map { number -> + Cardinal.create( + club = club, + cardinalNumber = number, + status = if (number == request.currentCardinal) CardinalStatus.IN_PROGRESS else CardinalStatus.DONE, + ) + } + + cardinalRepository.saveAll(cardinals) + + // LEAD 멤버를 최신 기수에 배정 + clubMemberCardinalRepository.save(ClubMemberCardinal.create(leadMember, cardinals.last())) + + return clubMapper.toCreateResponse(club) + } + + @Transactional + fun update( + clubId: Long, + userId: Long, + request: ClubUpdateRequest, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val club = clubRepository.getClubById(clubId) + + if (request.primaryContact == PrimaryContact.EMAIL) { + val resolvedEmail = request.contactEmail ?: club.clubContact.email + if (resolvedEmail == null) { + throw EmailRequiredForPrimaryContactException() + } + } + + request.profileImage?.let { image -> + deleteExistingFiles(FileOwnerType.CLUB_PROFILE, clubId) + saveFile(image, FileOwnerType.CLUB_PROFILE, clubId) + } + + request.backgroundImage?.let { image -> + deleteExistingFiles(FileOwnerType.CLUB_BACKGROUND, clubId) + saveFile(image, FileOwnerType.CLUB_BACKGROUND, clubId) + } + + club.update( + name = request.name, + schoolName = request.schoolName, + description = request.description, + contactEmail = request.contactEmail, + contactPhoneNumber = request.contactPhoneNumber, + primaryContact = request.primaryContact, + profileImageStorageKey = request.profileImage?.storageKey, + backgroundImageStorageKey = request.backgroundImage?.storageKey, + ) + } + + @Transactional + fun regenerateCode( + clubId: Long, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val club = clubRepository.getClubById(clubId) + val newCode = ClubCodePolicy.generateCode() + club.regenerateCode(newCode) + } + + @Transactional + fun deleteProfileImage( + clubId: Long, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val club = clubRepository.getClubById(clubId) + deleteExistingFiles(FileOwnerType.CLUB_PROFILE, clubId) + club.removeProfileImage() + } + + @Transactional + fun deleteBackgroundImage( + clubId: Long, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val club = clubRepository.getClubById(clubId) + deleteExistingFiles(FileOwnerType.CLUB_BACKGROUND, clubId) + club.removeBackgroundImage() + } + + private fun saveFileIfPresent( + request: FileSaveRequest?, + ownerType: FileOwnerType, + ownerId: Long, + ) { + request?.let { saveFile(it, ownerType, ownerId) } + } + + private fun saveFile( + request: FileSaveRequest, + ownerType: FileOwnerType, + ownerId: Long, + ) { + val file = + File.createUploaded( + fileName = request.fileName, + storageKey = request.storageKey, + fileSize = request.fileSize, + contentType = request.contentType, + ownerType = ownerType, + ownerId = ownerId, + ) + fileRepository.save(file) + } + + private fun deleteExistingFiles( + ownerType: FileOwnerType, + ownerId: Long, + ) { + val files = fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, FileStatus.UPLOADED) + + if (files.isNotEmpty()) { + fileRepository.deleteAll(files) + } + } + + private fun validatePrimaryContactEmail( + primaryContact: PrimaryContact, + contactEmail: String?, + ) { + if (primaryContact == PrimaryContact.EMAIL && contactEmail == null) { + throw EmailRequiredForPrimaryContactException() + } + } + + private fun checkDuplicateClubName( + schoolName: String, + clubName: String, + ) { + if (clubRepository.existsBySchoolNameAndName(schoolName, clubName)) { + throw DuplicateClubException() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt new file mode 100644 index 00000000..c24fe23d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt @@ -0,0 +1,76 @@ +package com.weeth.domain.club.application.usecase.query + +import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse +import com.weeth.domain.club.application.dto.response.ClubMemberResponse +import com.weeth.domain.club.application.dto.response.ClubMemberSummaryResponse +import com.weeth.domain.club.application.dto.response.ProfileStatusResponse +import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetClubMemberQueryService( + private val clubMemberReader: ClubMemberReader, + private val clubMemberCardinalReader: ClubMemberCardinalReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val clubMapper: ClubMapper, + private val userReader: UserReader, +) { + fun findClubMembersForAdmin( + clubId: Long, + userId: Long, + ): List { + clubPermissionPolicy.requireAdmin(clubId, userId) + val members = clubMemberReader.findAllByClubId(clubId) + + if (members.isEmpty()) { + return emptyList() + } + + val allMemberCardinals = clubMemberCardinalReader.findAllByClubMembers(members) + val memberCardinalMap = allMemberCardinals.groupBy { it.clubMember.id } + + return members.map { member -> + val cardinals = memberCardinalMap[member.id] ?: emptyList() + clubMapper.toMemberResponse(member, cardinals) + } + } + + fun findMyMemberProfile( + clubId: Long, + userId: Long, + ): ClubMemberProfileResponse { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val cardinals = clubMemberCardinalReader.findAllByClubMember(member) + + return clubMapper.toMemberProfileResponse(member, cardinals) + } + + fun findProfileStatus( + clubId: Long, + userId: Long, + ): ProfileStatusResponse { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val user = userReader.getById(userId) + val cardinalAssigned = clubMemberCardinalReader.findLatestCardinalByClubMember(member) != null + + return clubMapper.toProfileStatusResponse(user, cardinalAssigned) + } + + fun findMySummary( + clubId: Long, + userId: Long, + ): ClubMemberSummaryResponse { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val cardinals = clubMemberCardinalReader.findAllByClubMember(member) + + return clubMapper.toMemberSummaryResponse(member, cardinals) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt new file mode 100644 index 00000000..9126b1c0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt @@ -0,0 +1,76 @@ +package com.weeth.domain.club.application.usecase.query + +import com.weeth.domain.club.application.dto.response.ClubDetailResponse +import com.weeth.domain.club.application.dto.response.ClubInfoResponse +import com.weeth.domain.club.application.dto.response.ClubMembershipStatusResponse +import com.weeth.domain.club.application.dto.response.ClubPublicResponse +import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetClubQueryService( + private val clubReader: ClubReader, + private val clubMemberReader: ClubMemberReader, + private val clubMemberCardinalReader: ClubMemberCardinalReader, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val clubMapper: ClubMapper, +) { + fun findMyClubs(userId: Long): List { + val members = clubMemberReader.findAllByUserIdAndMemberStatusWithClub(userId, MemberStatus.ACTIVE) + if (members.isEmpty()) return emptyList() + + val cardinalsByMemberId = + clubMemberCardinalReader + .findAllByClubMembers(members) + .groupBy { it.clubMember.id } + + return members.map { member -> + val cardinals = cardinalsByMemberId[member.id] ?: emptyList() + val memberCount = clubMemberReader.countActiveByClubId(member.club.id) + clubMapper.toInfoResponse(member.club, member, cardinals, memberCount) + } + } + + fun findClub(clubId: Long): ClubPublicResponse { + val club = clubReader.getClubById(clubId) + + return clubMapper.toResponse(club) + } + + fun findClubDetailForAdmin( + clubId: Long, + userId: Long, + ): ClubDetailResponse { + clubPermissionPolicy.requireAdmin(clubId, userId) + val club = clubReader.getClubById(clubId) + + return clubMapper.toDetailResponse(club) + } + + fun findMembershipStatus(userId: Long): ClubMembershipStatusResponse { + val members = clubMemberReader.findAllByUserIdWithClub(userId) + if (members.isEmpty()) { + return clubMapper.toMembershipStatusResponse(members, emptyMap(), emptyMap()) + } + + val cardinalsByMemberId = + clubMemberCardinalReader + .findAllByClubMembers(members) + .groupBy { it.clubMember.id } + + val memberCountByClubId = + members + .map { it.club.id } + .distinct() + .associateWith { clubMemberReader.countActiveByClubId(it) } + + return clubMapper.toMembershipStatusResponse(members, cardinalsByMemberId, memberCountByClubId) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt new file mode 100644 index 00000000..6bcaa508 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt @@ -0,0 +1,174 @@ +package com.weeth.domain.club.domain.entity + +import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.global.common.entity.BaseEntity +import com.weeth.global.common.id.TsidGenerator +import jakarta.persistence.Column +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.PrePersist +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "club", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_club_school_name_club_name", + columnNames = ["school_name", "name"], + ), + ], +) +class Club( + name: String, + code: String, + description: String? = null, + schoolName: String, + clubContact: ClubContact, + profileImageStorageKey: String? = null, + backgroundImageStorageKey: String? = null, +) : BaseEntity() { + // TSID(Time-Sorted Unique Identifier)로 관리 + // Client 반환시 Base62 인코딩해서 String으로 반환 + @Id + @Column(name = "club_id") + var id: Long = 0L + private set + + @Column(nullable = false, unique = false, length = 100) + var name: String = name.trim() + private set + + @Column(nullable = false, unique = true, length = 36) + var code: String = code + private set + + @Column(length = 30) + var description: String? = description + private set + + @Column(length = 50) + var schoolName: String = schoolName + private set + + @Embedded + var clubContact: ClubContact = clubContact + private set + + @Column(name = "profile_image_url", length = 500) + var profileImageStorageKey: String? = profileImageStorageKey + private set + + @Column(name = "background_image_url", length = 500) + var backgroundImageStorageKey: String? = backgroundImageStorageKey + private set + + // todo: 동아리 삭제 지원 + + fun update( + name: String?, + schoolName: String?, + description: String?, + contactEmail: String?, + contactPhoneNumber: String?, + primaryContact: PrimaryContact?, + profileImageStorageKey: String?, + backgroundImageStorageKey: String?, + ) { + name?.let { + require(it.isNotBlank()) { "동아리 이름은 비어 있을 수 없습니다." } + this.name = it.trim() + } + schoolName?.let { + require(it.isNotBlank()) { "학교 이름은 비어 있을 수 없습니다." } + this.schoolName = it.trim() + } + description?.let { + require(it.length <= MAX_DESCRIPTION_LENGTH) { "소개글은 ${MAX_DESCRIPTION_LENGTH}자 이하여야 합니다." } + this.description = it + } + + updateContact(contactEmail, contactPhoneNumber, primaryContact) + updateImageStorageKey(profileImageStorageKey, backgroundImageStorageKey) + } + + private fun updateContact( + contactEmail: String?, + contactPhoneNumber: String?, + primaryContact: PrimaryContact?, + ) { + if (contactEmail != null || contactPhoneNumber != null || primaryContact != null) { + clubContact.update( + email = contactEmail, + phoneNumber = contactPhoneNumber, + primaryContact = primaryContact, + ) + } + } + + private fun updateImageStorageKey( + profileImageStorageKey: String?, + backgroundImageStorageKey: String?, + ) { + if (profileImageStorageKey != null || backgroundImageStorageKey != null) { + this.profileImageStorageKey = profileImageStorageKey ?: this.profileImageStorageKey + this.backgroundImageStorageKey = backgroundImageStorageKey ?: this.backgroundImageStorageKey + } + } + + fun regenerateCode(newCode: String) { + require(newCode.isNotBlank()) { "초대 코드는 비어 있을 수 없습니다." } + this.code = newCode + } + + fun removeProfileImage() { + this.profileImageStorageKey = null + } + + fun removeBackgroundImage() { + this.backgroundImageStorageKey = null + } + + @PrePersist + fun assignIdIfAbsent() { + if (id == 0L) { + id = TsidGenerator.nextId() + } + } + + companion object { + private const val MAX_DESCRIPTION_LENGTH = 30 + + fun create( + name: String, + code: String, + schoolName: String, + clubContact: ClubContact, + description: String? = null, + profileImageStorageKey: String? = null, + backgroundImageStorageKey: String? = null, + ): Club { + require(name.isNotBlank()) { "동아리 이름은 비어 있을 수 없습니다." } + require(code.isNotBlank()) { "초대 코드는 비어 있을 수 없습니다." } + require(schoolName.isNotBlank()) { "학교 이름은 비어 있을 수 없습니다." } + description?.let { + require(it.length <= MAX_DESCRIPTION_LENGTH) { "소개글은 ${MAX_DESCRIPTION_LENGTH}자 이하여야 합니다." } + } + return Club( + name = name, + code = code, + description = description, + schoolName = schoolName, + clubContact = clubContact, + profileImageStorageKey = profileImageStorageKey, + backgroundImageStorageKey = backgroundImageStorageKey, + ).apply { + // 객체 생성시 TSID 할당 + id = TsidGenerator.nextId() + } + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt new file mode 100644 index 00000000..10f905fd --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt @@ -0,0 +1,186 @@ +package com.weeth.domain.club.domain.entity + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.vo.ClubAttendanceStats +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "club_member", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_club_id_user_id", + columnNames = ["club_id", "user_id"], + ), + ], +) +class ClubMember( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + val club: Club, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + val user: User, + memberStatus: MemberStatus = MemberStatus.WAITING, + memberRole: MemberRole = MemberRole.USER, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "club_member_id") + var id: Long = 0L + private set + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var memberStatus: MemberStatus = memberStatus + private set + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var memberRole: MemberRole = memberRole + private set + + @Embedded + var attendanceStats: ClubAttendanceStats = ClubAttendanceStats() + private set + + @Column(nullable = false) + var penaltyCount: Int = 0 + private set + + @Column(length = 500) + var profileImageStorageKey: String? = null + private set + + @Column(length = 30) + var bio: String? = null + private set + + fun accept() { + check(memberStatus == MemberStatus.WAITING) { "대기 상태인 멤버만 승인할 수 있습니다." } + memberStatus = MemberStatus.ACTIVE + // TODO: BANNED 복구가 필요해지면 accept()에 섞지 말고 별도 unban/restore 정책과 API로 분리 + } + + fun ban() { + check(memberStatus != MemberStatus.BANNED) { "이미 차단된 멤버입니다." } + check(memberStatus != MemberStatus.LEFT) { "탈퇴한 멤버는 차단할 수 없습니다." } + memberStatus = MemberStatus.BANNED + } + + fun restore() { + check(memberStatus == MemberStatus.BANNED) { "차단된 멤버만 복구할 수 있습니다." } + memberStatus = MemberStatus.ACTIVE + } + + fun leave() { + check(memberStatus == MemberStatus.ACTIVE) { "활동 중인 멤버만 탈퇴할 수 있습니다." } + memberStatus = MemberStatus.LEFT + } + + fun isActive(): Boolean = memberStatus == MemberStatus.ACTIVE + + fun updateRole(role: MemberRole) { + check(role != MemberRole.LEAD) { "LEAD는 이양을 통해서만 변경할 수 있습니다." } + check(!isLead()) { "LEAD의 권한은 이양을 통해서만 변경할 수 있습니다." } + this.memberRole = role + } + + fun isAdminOrLead(): Boolean = memberRole.isAdminOrLead() + + fun isLead(): Boolean = memberRole == MemberRole.LEAD + + fun releaseLead() { + check(isLead()) { "LEAD만 권한을 내려놓을 수 있습니다." } + this.memberRole = MemberRole.ADMIN + } + + fun assignLead() { + this.memberRole = MemberRole.LEAD + } + + fun attend() { + attendanceStats.attend() + } + + fun removeAttend() { + attendanceStats.removeAttend() + } + + fun absent() { + attendanceStats.absent() + } + + fun removeAbsent() { + attendanceStats.removeAbsent() + } + + fun resetAttendanceStats() { + attendanceStats.reset() + } + + fun recalculateAttendanceStats( + attendCount: Int, + absentCount: Int, + ) { + attendanceStats.recalculate(attendCount, absentCount) + } + + fun incrementPenaltyCount() { + penaltyCount++ + } + + fun resetPenaltyCount() { + penaltyCount = 0 + } + + fun recalculatePenaltyCount(count: Int) { + require(count >= 0) { "패널티 수는 0 이상이어야 합니다." } + penaltyCount = count + } + + fun updateProfileImageUrl(storageKey: String?) { + val trimmed = storageKey?.trim()?.takeIf { it.isNotBlank() } + require((trimmed?.length ?: 0) <= 500) { "프로필 이미지 storageKey는 500자 이하여야 합니다." } + this.profileImageStorageKey = trimmed + } + + fun removeProfileImage() { + this.profileImageStorageKey = null + } + + fun updateBio(bio: String?) { + val trimmed = bio?.trim()?.takeIf { it.isNotBlank() } + require((trimmed?.length ?: 0) <= 30) { "자기소개는 30자 이하여야 합니다." } + this.bio = trimmed + } + + fun decrementPenaltyCount() { + if (penaltyCount > 0) { + penaltyCount-- + } + } + + companion object { + fun create( + club: Club, + user: User, + memberRole: MemberRole = MemberRole.USER, + ): ClubMember = ClubMember(club = club, user = user, memberRole = memberRole) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMemberCardinal.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMemberCardinal.kt new file mode 100644 index 00000000..93b7f626 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMemberCardinal.kt @@ -0,0 +1,44 @@ +package com.weeth.domain.club.domain.entity + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "club_member_cardinal", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_club_member_id_cardinal_id", + columnNames = ["club_member_id", "cardinal_id"], + ), + ], +) +class ClubMemberCardinal( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_member_id", nullable = false) + val clubMember: ClubMember, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cardinal_id", nullable = false) + val cardinal: Cardinal, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + companion object { + fun create( + clubMember: ClubMember, + cardinal: Cardinal, + ): ClubMemberCardinal = ClubMemberCardinal(clubMember = clubMember, cardinal = cardinal) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt new file mode 100644 index 00000000..c0a17973 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.club.domain.enums + +enum class MemberRole { + USER, + ADMIN, + LEAD, // 동아리 개설한 인원의 역할. 추후 LEAD 권한 이양 API도 추가 + ; + + fun isAdminOrLead(): Boolean = this == ADMIN || this == LEAD +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberStatus.kt b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberStatus.kt new file mode 100644 index 00000000..afd6ebcf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberStatus.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.club.domain.enums + +enum class MemberStatus { + WAITING, + ACTIVE, + BANNED, + LEFT, +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/enums/PrimaryContact.kt b/src/main/kotlin/com/weeth/domain/club/domain/enums/PrimaryContact.kt new file mode 100644 index 00000000..e74bb7b4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/enums/PrimaryContact.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.club.domain.enums + +enum class PrimaryContact { + EMAIL, + PHONE, +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt new file mode 100644 index 00000000..a733ce97 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberStatus + +interface ClubMemberCardinalReader { + fun findAllByClubMember(clubMember: ClubMember): List + + fun findAllByClubMembers(clubMembers: List): List + + fun findAllByClubIdAndCardinalNumber( + clubId: Long, + cardinalNumber: Int, + status: MemberStatus, + ): List + + fun findLatestCardinalByClubMember(clubMember: ClubMember): ClubMemberCardinal? + + fun existsByClubMemberAndCardinalId( // todo: 실제 사용처에 따라 파라미터 확정 + clubMember: ClubMember, + cardinalId: Long, + ): Boolean +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt new file mode 100644 index 00000000..861421fc --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt @@ -0,0 +1,64 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberStatus +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param + +interface ClubMemberCardinalRepository : + JpaRepository, + ClubMemberCardinalReader { + override fun findAllByClubMember(clubMember: ClubMember): List + + fun existsByClubMember(clubMember: ClubMember): Boolean + + @Query( + """ + SELECT cmc + FROM ClubMemberCardinal cmc + JOIN FETCH cmc.cardinal + WHERE cmc.clubMember IN :clubMembers + """, + ) + override fun findAllByClubMembers( + @Param("clubMembers") clubMembers: List, + ): List + + @Query( + "SELECT cmc FROM ClubMemberCardinal cmc " + + "JOIN cmc.cardinal c " + + "WHERE cmc.clubMember = :clubMember " + + "ORDER BY c.cardinalNumber DESC " + + "LIMIT 1", + ) + fun findTopByClubMemberOrderedByCardinalNumberDesc(clubMember: ClubMember): ClubMemberCardinal? + + override fun findLatestCardinalByClubMember(clubMember: ClubMember): ClubMemberCardinal? = + findTopByClubMemberOrderedByCardinalNumberDesc(clubMember) + + @Query( + """ + SELECT cmc + FROM ClubMemberCardinal cmc + JOIN FETCH cmc.clubMember cm + JOIN FETCH cm.club + JOIN FETCH cm.user + JOIN FETCH cmc.cardinal + WHERE cm.club.id = :clubId + AND cmc.cardinal.cardinalNumber = :cardinalNumber + AND cm.memberStatus = :status + """, + ) + override fun findAllByClubIdAndCardinalNumber( + @Param("clubId") clubId: Long, + @Param("cardinalNumber") cardinalNumber: Int, + @Param("status") status: MemberStatus, + ): List + + override fun existsByClubMemberAndCardinalId( + clubMember: ClubMember, + cardinalId: Long, + ): Boolean +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt new file mode 100644 index 00000000..15533b86 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -0,0 +1,58 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus + +interface ClubMemberReader { + fun findByIdWithLock(clubMemberId: Long): ClubMember? + + /** + * 비관적 쓰기 락(PESSIMISTIC_WRITE)으로 여러 ClubMember를 조회한다. + * 교착 방지를 위해 id 오름차순으로 락을 획득하며, 호출부에서도 [ids]를 정렬하여 전달해야 한다. + */ + fun findAllByIdsWithLock(ids: List): List + + fun findByIdOrNull(clubMemberId: Long): ClubMember? + + fun findByClubIdAndUserId( + clubId: Long, + userId: Long, + ): ClubMember? + + fun findByClubIdAndUserIdWithLock( + clubId: Long, + userId: Long, + ): ClubMember? + + fun findAllByClubId(clubId: Long): List + + fun findAllByUserId(userId: Long): List + + fun findAllByUserIdWithClub(userId: Long): List + + fun findAllByUserIdAndMemberStatusWithClub( + userId: Long, + memberStatus: MemberStatus, + ): List + + fun findActiveByUserId(userId: Long): List + + fun countActiveByClubId(clubId: Long): Long + + fun findAllByClubIdAndMemberStatus( + clubId: Long, + memberStatus: MemberStatus, + ): List + + fun countByUserIdAndMemberStatusAndMemberRole( + userId: Long, + memberStatus: MemberStatus, + memberRole: MemberRole, + ): Long + + fun findAllByClubIdAndUserIds( + clubId: Long, + userIds: List, + ): List +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt new file mode 100644 index 00000000..9b0e810a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -0,0 +1,143 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +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.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param + +interface ClubMemberRepository : + JpaRepository, + ClubMemberReader { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT cm FROM ClubMember cm JOIN FETCH cm.user WHERE cm.id = :clubMemberId") + override fun findByIdWithLock( + @Param("clubMemberId") clubMemberId: Long, + ): ClubMember? + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT cm FROM ClubMember cm JOIN FETCH cm.user JOIN FETCH cm.club WHERE cm.id IN :ids ORDER BY cm.id ASC") + override fun findAllByIdsWithLock( + @Param("ids") ids: List, + ): List + + override fun findAllByClubIdAndMemberStatus( + clubId: Long, + memberStatus: MemberStatus, + ): List + + override fun findByIdOrNull(clubMemberId: Long): ClubMember? = findById(clubMemberId).orElse(null) + + override fun findByClubIdAndUserId( + clubId: Long, + userId: Long, + ): ClubMember? + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT cm FROM ClubMember cm WHERE cm.club.id = :clubId AND cm.user.id = :userId") + override fun findByClubIdAndUserIdWithLock( + @Param("clubId") clubId: Long, + @Param("userId") userId: Long, + ): ClubMember? + + @Query( + """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user + WHERE cm.club.id = :clubId + """, + ) + override fun findAllByClubId( + @Param("clubId") clubId: Long, + ): List + + override fun findAllByUserId(userId: Long): List + + @Query( + """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.club + WHERE cm.user.id = :userId + """, + ) + override fun findAllByUserIdWithClub( + @Param("userId") userId: Long, + ): List + + @Query( + """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.club + WHERE cm.user.id = :userId + AND cm.memberStatus = :memberStatus + """, + ) + override fun findAllByUserIdAndMemberStatusWithClub( + @Param("userId") userId: Long, + @Param("memberStatus") memberStatus: MemberStatus, + ): List + + @Query( + """ + SELECT cm + FROM ClubMember cm + WHERE cm.user.id = :userId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + """, + ) + override fun findActiveByUserId( + @Param("userId") userId: Long, + ): List + + @Query( + """ + SELECT COUNT(cm) + FROM ClubMember cm + WHERE cm.club.id = :clubId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + """, + ) + override fun countActiveByClubId( + @Param("clubId") clubId: Long, + ): Long + + @Query( + """ + SELECT COUNT(cm) + FROM ClubMember cm + WHERE cm.user.id = :userId + AND cm.memberStatus = :memberStatus + AND cm.memberRole = :memberRole + """, + ) + override fun countByUserIdAndMemberStatusAndMemberRole( + @Param("userId") userId: Long, + @Param("memberStatus") memberStatus: MemberStatus, + @Param("memberRole") memberRole: MemberRole, + ): Long + + @Query( + """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user + WHERE cm.club.id = :clubId + AND cm.user.id IN :userIds + """, + ) + override fun findAllByClubIdAndUserIds( + @Param("clubId") clubId: Long, + @Param("userIds") userIds: List, + ): List +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubReader.kt new file mode 100644 index 00000000..3939502d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubReader.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.domain.entity.Club + +interface ClubReader { + fun getClubById(clubId: Long): Club + + fun getClubByIdForUpdate(clubId: Long): Club + + fun findByIdOrNull(clubId: Long): Club? + + fun findClubByCode(code: String): Club? + + fun findClubByName(name: String): Club? +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt new file mode 100644 index 00000000..2ce690a8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt @@ -0,0 +1,39 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.application.exception.ClubNotFoundException +import com.weeth.domain.club.domain.entity.Club +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +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.jpa.repository.QueryHints +import java.util.Optional + +interface ClubRepository : + JpaRepository, + ClubReader { + fun findByCode(code: String): Optional + + fun findByName(name: String): Optional + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT c FROM Club c WHERE c.id = :clubId") + fun findByIdWithLock(clubId: Long): Club? + + fun existsBySchoolNameAndName( + schoolName: String, + name: String, + ): Boolean + + override fun getClubById(clubId: Long): Club = findById(clubId).orElseThrow { ClubNotFoundException() } + + override fun getClubByIdForUpdate(clubId: Long): Club = findByIdWithLock(clubId) ?: throw ClubNotFoundException() + + override fun findByIdOrNull(clubId: Long): Club? = findById(clubId).orElse(null) + + override fun findClubByCode(code: String): Club? = findByCode(code).orElse(null) + + override fun findClubByName(name: String): Club? = findByName(name).orElse(null) +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicy.kt new file mode 100644 index 00000000..a4ead92a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicy.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.InvalidClubCodeException +import java.util.UUID + +/** + * 동아리 초대 코드 생성 및 검증 정책. + * 형식: UUID(36자) + */ +object ClubCodePolicy { + fun generateCode(): String = UUID.randomUUID().toString() + + /** + * 제공된 코드가 클럽의 초대 코드와 일치하는지 검증 + */ + fun validate( + clubCode: String, + providedCode: String, + ) { + if (!clubCode.equals(providedCode.trim(), ignoreCase = true)) { + throw InvalidClubCodeException() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicy.kt new file mode 100644 index 00000000..28361f02 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicy.kt @@ -0,0 +1,51 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException +import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberReader +import org.springframework.stereotype.Service + +/** + * 동아리 가입/생성 수 제한 검증 정책 + */ +@Service +class ClubJoinPolicy( + private val clubMemberReader: ClubMemberReader, +) { + /** + * 일반 멤버(USER)로 가입 가능한 동아리 수 제한 검증 + */ + fun validateJoinLimit(userId: Long) { + val activeUserCount = + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + userId, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + if (activeUserCount >= MAX_USER_CLUBS) { + throw ClubJoinLimitExceededException() + } + } + + /** + * 동아리장(LEAD)으로 생성 가능한 동아리 수 제한 검증 + */ + fun validateCreateLimit(userId: Long) { + val activeLeadCount = + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + userId, + MemberStatus.ACTIVE, + MemberRole.LEAD, + ) + if (activeLeadCount >= MAX_LEAD_CLUBS) { + throw ClubCreateLimitExceededException() + } + } + + companion object { + private const val MAX_LEAD_CLUBS = 1 + private const val MAX_USER_CLUBS = 1 + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicy.kt new file mode 100644 index 00000000..7dc653f6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicy.kt @@ -0,0 +1,37 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import org.springframework.stereotype.Service + +@Service +class ClubMemberCardinalPolicy( + private val clubMemberCardinalReader: ClubMemberCardinalReader, +) { + fun getCurrentCardinal(clubMember: ClubMember): Cardinal { + val latest = + clubMemberCardinalReader.findLatestCardinalByClubMember(clubMember) + ?: throw CardinalNotFoundException() + return latest.cardinal + } + + fun notContains( + clubMember: ClubMember, + cardinal: Cardinal, + ): Boolean = !clubMemberCardinalReader.existsByClubMemberAndCardinalId(clubMember, cardinal.id) + + /** + * applyOb에서 다음 기수로 등록하기 위해 사용하는 메서드 + * 하위호환을 위해 기수가 없는 경우라도 다음 기수 활동이 가능하도록 지원 + * TODO: 앞 단에서 기수가 필수로 저장됨을 보장해야함. (가입, 기수 추가 등) + */ + fun isLatestOrFirstCardinal( + clubMember: ClubMember, + cardinal: Cardinal, + ): Boolean { + val latest = clubMemberCardinalReader.findLatestCardinalByClubMember(clubMember) + return latest == null || cardinal.cardinalNumber > latest.cardinal.cardinalNumber + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt new file mode 100644 index 00000000..489ca4d8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt @@ -0,0 +1,63 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException +import com.weeth.domain.club.application.exception.ClubMemberNotInClubException +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberReader +import org.springframework.stereotype.Service + +/** + * 동아리 멤버 조회 및 상태 검증 + * TODO: 캐싱 도입 + */ +@Service +class ClubMemberPolicy( + private val clubMemberReader: ClubMemberReader, +) { + /** + * 동아리의 활성 멤버를 조회 + * 한 번 조회 후 분기하여 불필요한 중복 쿼리를 방지 + */ + fun getActiveMember( + clubId: Long, + userId: Long, + ): ClubMember = resolveActiveMember { clubMemberReader.findByClubIdAndUserId(clubId, userId) } + + fun getActiveMemberWithLock( + clubId: Long, + userId: Long, + ): ClubMember = resolveActiveMember { clubMemberReader.findByClubIdAndUserIdWithLock(clubId, userId) } + + fun getMemberInClub( + clubId: Long, + clubMemberId: Long, + ): ClubMember = resolveMemberInClub(clubId) { clubMemberReader.findByIdOrNull(clubMemberId) } + + private fun resolveActiveMember(reader: () -> ClubMember?): ClubMember { + val member = reader() ?: throw ClubMemberNotFoundException() + if (!member.isActive()) throw MemberNotActiveException() + return member + } + + private fun resolveMemberInClub( + clubId: Long, + reader: () -> ClubMember?, + ): ClubMember { + val member = reader() ?: throw ClubMemberNotFoundException() + if (member.club.id != clubId) throw ClubMemberNotInClubException() + return member + } + + fun getActiveMemberInClubWithLock( + clubId: Long, + clubMemberId: Long, + ): ClubMember { + val member = + clubMemberReader.findByIdWithLock(clubMemberId) + ?: throw ClubMemberNotFoundException() + if (member.club.id != clubId) throw ClubMemberNotInClubException() + if (!member.isActive()) throw MemberNotActiveException() + return member + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicy.kt new file mode 100644 index 00000000..c8af8f3b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicy.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.NotClubAdminException +import com.weeth.domain.club.domain.entity.ClubMember +import org.springframework.stereotype.Service + +/** + * 동아리 관리자 권한 검증 정책 + */ +@Service +class ClubPermissionPolicy( + private val clubMemberPolicy: ClubMemberPolicy, +) { + /** + * 사용자가 동아리 관리자인지 검증 + * 활성 상태이고 + ADMIN 또는 LEAD 권한 + */ + fun requireAdmin( + clubId: Long, + userId: Long, + ): ClubMember = + clubMemberPolicy.getActiveMember(clubId, userId).also { + if (!it.isAdminOrLead()) { + throw NotClubAdminException() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubAttendanceStats.kt b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubAttendanceStats.kt new file mode 100644 index 00000000..8cb8737b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubAttendanceStats.kt @@ -0,0 +1,67 @@ +package com.weeth.domain.club.domain.vo + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +@Embeddable +class ClubAttendanceStats( + attendanceCount: Int = 0, + absenceCount: Int = 0, + attendanceRate: Int = 0, +) { + @Column(name = "attendance_count") + var attendanceCount: Int = attendanceCount + private set + + @Column(name = "absence_count") + var absenceCount: Int = absenceCount + private set + + @Column(name = "attendance_rate") + var attendanceRate: Int = attendanceRate + private set + + fun reset() { + attendanceCount = 0 + absenceCount = 0 + attendanceRate = 0 + } + + fun recalculate( + attendCount: Int, + absentCount: Int, + ) { + attendanceCount = attendCount + absenceCount = absentCount + recalculateRate() + } + + fun attend() { + attendanceCount++ + recalculateRate() + } + + fun removeAttend() { + if (attendanceCount > 0) { + attendanceCount-- + recalculateRate() + } + } + + fun absent() { + absenceCount++ + recalculateRate() + } + + fun removeAbsent() { + if (absenceCount > 0) { + absenceCount-- + recalculateRate() + } + } + + private fun recalculateRate() { + val total = attendanceCount + absenceCount + attendanceRate = if (total > 0) (attendanceCount * 100) / total else 0 + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubContact.kt b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubContact.kt new file mode 100644 index 00000000..f9e22d86 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubContact.kt @@ -0,0 +1,64 @@ +package com.weeth.domain.club.domain.vo + +import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.global.common.vo.PhoneNumber +import jakarta.persistence.Column +import jakarta.persistence.Embeddable +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated + +/** + * 동아리 연락처를 저장하기 위한 VO. + * 전화번호는 필수이며, 이메일은 선택 사항이다. + * primaryContact는 주 연락처를 나타낸다. EMAIL을 선택하려면 이메일이 반드시 존재해야 한다. + */ +@Embeddable +class ClubContact( + email: String? = null, + phoneNumber: String, + primaryContact: PrimaryContact, +) { + @Column(name = "contact_email", length = 100) + var email: String? = email + private set + + @Column(name = "contact_phone_number", nullable = false, length = 20) + var phoneNumber: String = PhoneNumber.from(phoneNumber).value + private set + + @Enumerated(EnumType.STRING) + @Column(name = "primary_contact", nullable = false, length = 10) + var primaryContact: PrimaryContact = primaryContact + private set + + fun update( + email: String?, + phoneNumber: String?, + primaryContact: PrimaryContact?, + ) { + phoneNumber?.let { + this.phoneNumber = PhoneNumber.from(it).value + } + this.email = email ?: this.email + primaryContact?.let { + if (it == PrimaryContact.EMAIL) { + val resolvedEmail = email ?: this.email + require(resolvedEmail != null) { "주 연락처를 이메일로 설정하려면 이메일을 입력해야 합니다." } + } + this.primaryContact = it + } + } + + companion object { + fun from( + email: String?, + phoneNumber: String, + primaryContact: PrimaryContact, + ): ClubContact { + if (primaryContact == PrimaryContact.EMAIL) { + require(email != null) { "주 연락처를 이메일로 설정하려면 이메일을 입력해야 합니다." } + } + return ClubContact(email = email, phoneNumber = phoneNumber, primaryContact = primaryContact) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt new file mode 100644 index 00000000..753d6b7f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt @@ -0,0 +1,195 @@ +package com.weeth.domain.club.presentation + +import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest +import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest +import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberCardinalRequest +import com.weeth.domain.club.application.dto.response.ClubDetailResponse +import com.weeth.domain.club.application.dto.response.ClubMemberResponse +import com.weeth.domain.club.application.exception.ClubErrorCode +import com.weeth.domain.club.application.usecase.command.AdminClubMemberUseCase +import com.weeth.domain.club.application.usecase.command.ManageClubUseCase +import com.weeth.domain.club.application.usecase.query.GetClubMemberQueryService +import com.weeth.domain.club.application.usecase.query.GetClubQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "CLUB-ADMIN", description = "동아리 관리자 API") +@RestController +@RequestMapping("/api/v4/admin/clubs/{clubId}") +@ApiErrorCodeExample(ClubErrorCode::class) +class ClubAdminController( + private val manageClubUseCase: ManageClubUseCase, + private val adminClubMemberUseCase: AdminClubMemberUseCase, + private val getClubQueryService: GetClubQueryService, + private val getClubMemberQueryService: GetClubMemberQueryService, +) { + @GetMapping + @Operation(summary = "동아리 상세 정보 조회") + fun getClubDetail( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse { + val detail = getClubQueryService.findClubDetailForAdmin(clubId, userId) + return CommonResponse.success(ClubResponseCode.CLUB_FIND_BY_ID_SUCCESS, detail) + } + + @PatchMapping + @Operation(summary = "동아리 정보 수정") + fun update( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + @Valid @RequestBody request: ClubUpdateRequest, + ): CommonResponse { + manageClubUseCase.update(clubId, userId, request) + return CommonResponse.success(ClubResponseCode.CLUB_UPDATED_SUCCESS) + } + + @DeleteMapping("/profile-image") + @Operation(summary = "동아리 프로필 사진 삭제") + fun deleteProfileImage( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse { + manageClubUseCase.deleteProfileImage(clubId, userId) + return CommonResponse.success(ClubResponseCode.CLUB_PROFILE_IMAGE_DELETED_SUCCESS) + } + + @DeleteMapping("/background-image") + @Operation(summary = "동아리 배경 사진 삭제") + fun deleteBackgroundImage( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse { + manageClubUseCase.deleteBackgroundImage(clubId, userId) + return CommonResponse.success(ClubResponseCode.CLUB_BACKGROUND_IMAGE_DELETED_SUCCESS) + } + + @PostMapping("/code/regenerate") + @Operation(summary = "초대 코드 재생성 (MVP 미사용)", deprecated = true) + fun regenerateCode( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse { + manageClubUseCase.regenerateCode(clubId, userId) + return CommonResponse.success(ClubResponseCode.CLUB_CODE_REGENERATED_SUCCESS) + } + + @GetMapping("/members") + @Operation(summary = "동아리 멤버 목록 조회") + fun getClubMembers( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse> { + val members = getClubMemberQueryService.findClubMembersForAdmin(clubId, userId) + return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ALL_SUCCESS, members) + } + + @PatchMapping("/members/{clubMemberId}/accept") + @Operation(summary = "멤버 승인", deprecated = true) + fun acceptMember( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable clubMemberId: Long, + ): CommonResponse { + adminClubMemberUseCase.accept(clubId, userId, clubMemberId) + return CommonResponse.success(ClubResponseCode.MEMBER_ACCEPTED_SUCCESS) + } + + @DeleteMapping("/members/{clubMemberId}/ban") + @Operation(summary = "멤버 추방") + fun banMember( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable clubMemberId: Long, + ): CommonResponse { + adminClubMemberUseCase.ban(clubId, userId, clubMemberId) + return CommonResponse.success(ClubResponseCode.MEMBER_BANNED_SUCCESS) + } + + @PatchMapping("/members/{clubMemberId}/restore") + @Operation(summary = "추방 멤버 복구") + fun restoreMember( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable clubMemberId: Long, + ): CommonResponse { + adminClubMemberUseCase.restore(clubId, userId, clubMemberId) + return CommonResponse.success(ClubResponseCode.MEMBER_RESTORED_SUCCESS) + } + + @PatchMapping("/members/{clubMemberId}/role") + @Operation(summary = "멤버 권한 변경") + fun updateMemberRole( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable clubMemberId: Long, + @Valid @RequestBody request: ClubMemberRoleUpdateRequest, + ): CommonResponse { + adminClubMemberUseCase.updateMemberRole(clubId, userId, clubMemberId, request) + return CommonResponse.success(ClubResponseCode.MEMBER_ROLE_UPDATED_SUCCESS) + } + + @PatchMapping("/members/{targetClubMemberId}/lead") + @Operation(summary = "LEAD 권한 이양") + fun transferLead( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @PathVariable targetClubMemberId: Long, + ): CommonResponse { + adminClubMemberUseCase.transferLead(clubId, userId, targetClubMemberId) + return CommonResponse.success(ClubResponseCode.LEAD_TRANSFERRED_SUCCESS) + } + + @PatchMapping("/members/{clubMemberId}/cardinals") + @Operation(summary = "멤버 기수 수정") + @ApiErrorCodeExample(ClubErrorCode::class) + fun updateMemberCardinals( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable clubMemberId: Long, + @Valid @RequestBody request: UpdateMemberCardinalRequest, + ): CommonResponse { + adminClubMemberUseCase.updateCardinals(clubId, userId, clubMemberId, request) + return CommonResponse.success(ClubResponseCode.MEMBER_CARDINAL_UPDATED_SUCCESS) + } + + @PatchMapping("/members/apply-ob") + @Operation(summary = "멤버 OB 기수 등록", deprecated = true) + fun applyOb( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + @Valid @RequestBody requests: List, + ): CommonResponse { + adminClubMemberUseCase.applyOb(clubId, userId, requests) + return CommonResponse.success(ClubResponseCode.MEMBER_APPLY_OB_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt new file mode 100644 index 00000000..ccc20cbc --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt @@ -0,0 +1,80 @@ +package com.weeth.domain.club.presentation + +import com.weeth.domain.club.application.dto.request.ClubCreateRequest +import com.weeth.domain.club.application.dto.response.ClubCreateResponse +import com.weeth.domain.club.application.dto.response.ClubInfoResponse +import com.weeth.domain.club.application.dto.response.ClubMembershipStatusResponse +import com.weeth.domain.club.application.dto.response.ClubPublicResponse +import com.weeth.domain.club.application.exception.ClubErrorCode +import com.weeth.domain.club.application.usecase.command.ManageClubUseCase +import com.weeth.domain.club.application.usecase.query.GetClubQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.security.SecurityRequirements +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "CLUB", description = "동아리 API") +@RestController +@RequestMapping("/api/v4/clubs") +@ApiErrorCodeExample(ClubErrorCode::class) +class ClubController( + private val manageClubUseCase: ManageClubUseCase, + private val getClubQueryService: GetClubQueryService, +) { + @PostMapping + @Operation(summary = "동아리 생성") + @ResponseStatus(HttpStatus.CREATED) + fun create( + @Parameter(hidden = true) @CurrentUser userId: Long, + @Valid @RequestBody request: ClubCreateRequest, + ): CommonResponse { + val response = manageClubUseCase.create(userId, request) + + return CommonResponse.success(ClubResponseCode.CLUB_CREATED_SUCCESS, response) + } + + @GetMapping + @Operation(summary = "내가 가입한 동아리 목록 조회") + fun getMyClubs( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> { + val clubs = getClubQueryService.findMyClubs(userId) + + return CommonResponse.success(ClubResponseCode.CLUB_FIND_ALL_SUCCESS, clubs) + } + + @GetMapping("/{clubId}") + @Operation(summary = "동아리 공개 정보 조회 (이름, 소개, 프로필 사진) - 인증 불필요") + @SecurityRequirements + fun getClubPublicInfo( + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse { + val info = getClubQueryService.findClub(clubId) + + return CommonResponse.success(ClubResponseCode.CLUB_FIND_SUCCESS, info) + } + + @GetMapping("/membership-status") + @Operation(summary = "동아리 가입 여부 조회") + fun getMembershipStatus( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + val status = getClubQueryService.findMembershipStatus(userId) + + return CommonResponse.success(ClubResponseCode.MEMBERSHIP_STATUS_FIND_SUCCESS, status) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubMemberController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubMemberController.kt new file mode 100644 index 00000000..1c94f392 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubMemberController.kt @@ -0,0 +1,134 @@ +package com.weeth.domain.club.presentation + +import com.weeth.domain.club.application.dto.request.ClubJoinRequest +import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberProfileRequest +import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse +import com.weeth.domain.club.application.dto.response.ClubMemberSummaryResponse +import com.weeth.domain.club.application.dto.response.ProfileStatusResponse +import com.weeth.domain.club.application.exception.ClubErrorCode +import com.weeth.domain.club.application.usecase.command.ManageClubMemberUsecase +import com.weeth.domain.club.application.usecase.query.GetClubMemberQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "CLUB MEMBER", description = "동아리 멤버 API") +@RestController +@RequestMapping("/api/v4/clubs") +@ApiErrorCodeExample(ClubErrorCode::class) +class ClubMemberController( + private val manageClubMemberUsecase: ManageClubMemberUsecase, + private val getClubMemberQueryService: GetClubMemberQueryService, +) { + @PostMapping("/{clubId}/join") + @Operation(summary = "동아리 가입") + fun join( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + @Valid @RequestBody request: ClubJoinRequest, + ): CommonResponse { + manageClubMemberUsecase.join(clubId, userId, request) + + return CommonResponse.success(ClubResponseCode.CLUB_JOINED_SUCCESS) + } + + @DeleteMapping("/{clubId}/leave") + @Operation(summary = "동아리 탈퇴") + fun leave( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse { + manageClubMemberUsecase.leave(clubId, userId) + + return CommonResponse.success(ClubResponseCode.CLUB_LEFT_SUCCESS) + } + + @GetMapping("/{clubId}/members/me") + @Operation(summary = "내 멤버 정보 조회") + fun getMyMemberInfo( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse { + val meInfo = getClubMemberQueryService.findMyMemberProfile(clubId, userId) + + return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ME_SUCCESS, meInfo) + } + + @GetMapping("/{clubId}/members/me/summary") + @Operation(summary = "내 동아리 활동 요약 정보 조회") + fun getMyMemberSummary( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse { + val summary = getClubMemberQueryService.findMySummary(clubId, userId) + + return CommonResponse.success(ClubResponseCode.MEMBER_SUMMARY_FIND_SUCCESS, summary) + } + + // TODO: 추후 동아리별 프로필 수정으로 변경 시 clubId 경로 변수 추가 및 단일 ClubMember만 수정하도록 변경 + @PatchMapping("/members/me") + @Operation(summary = "내 클럽 활동 프로필 수정 (프로필 사진, 자기소개)") + fun updateMyProfile( + @Parameter(hidden = true) @CurrentUser userId: Long, + @Valid @RequestBody request: UpdateMemberProfileRequest, + ): CommonResponse { + manageClubMemberUsecase.updateProfile(userId, request) + return CommonResponse.success(ClubResponseCode.MEMBER_PROFILE_UPDATED_SUCCESS) + } + + // TODO: 추후 동아리별 프로필 수정으로 변경 시 clubId 경로 변수 추가 및 단일 ClubMember만 수정하도록 변경 + @DeleteMapping("/members/me/profile-image") + @Operation(summary = "동아리 프로필 사진 삭제") + fun deleteMyProfileImage( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageClubMemberUsecase.deleteProfileImage(userId) + return CommonResponse.success(ClubResponseCode.MEMBER_PROFILE_IMAGE_DELETED_SUCCESS) + } + + @GetMapping("/{clubId}/members/me/profile-status") + @Operation(summary = "프로필 완성 상태 조회", description = "로그인 후 혹은 홈 접속시 최초 1회만 호출하고 상태를 저장해서 처리해주세요.") + fun getProfileStatus( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + val status = getClubMemberQueryService.findProfileStatus(clubId, userId) + + return CommonResponse.success(ClubResponseCode.PROFILE_STATUS_FIND_SUCCESS, status) + } + + @PostMapping("/{clubId}/members/me/cardinals") + @Operation(summary = "활동 기수 최초 설정 (최초 1회만 가능)") + @ResponseStatus(HttpStatus.CREATED) + fun setInitialCardinals( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @Valid @RequestBody request: ClubMemberCardinalSetRequest, + ): CommonResponse { + manageClubMemberUsecase.setInitialCardinals(clubId, userId, request) + + return CommonResponse.success(ClubResponseCode.MEMBER_CARDINAL_SET_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt new file mode 100644 index 00000000..d5341fa0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt @@ -0,0 +1,36 @@ +package com.weeth.domain.club.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class ClubResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + CLUB_CREATED_SUCCESS(11100, HttpStatus.CREATED, "동아리가 성공적으로 생성되었습니다."), + CLUB_FIND_ALL_SUCCESS(11101, HttpStatus.OK, "동아리 목록을 성공적으로 조회했습니다."), + CLUB_FIND_BY_ID_SUCCESS(11102, HttpStatus.OK, "동아리 정보를 성공적으로 조회했습니다."), + CLUB_UPDATED_SUCCESS(11103, HttpStatus.OK, "동아리 정보가 성공적으로 수정되었습니다."), + CLUB_CODE_REGENERATED_SUCCESS(11104, HttpStatus.OK, "초대 코드가 재생성되었습니다."), + CLUB_JOINED_SUCCESS(11105, HttpStatus.OK, "동아리에 가입했습니다."), + CLUB_LEFT_SUCCESS(11106, HttpStatus.OK, "동아리를 탈퇴했습니다."), + MEMBER_FIND_ALL_SUCCESS(11107, HttpStatus.OK, "동아리 멤버 목록을 성공적으로 조회했습니다."), + MEMBER_FIND_ME_SUCCESS(11108, HttpStatus.OK, "내 멤버 정보를 성공적으로 조회했습니다."), + MEMBER_ACCEPTED_SUCCESS(11109, HttpStatus.OK, "멤버가 승인되었습니다."), + MEMBER_BANNED_SUCCESS(11110, HttpStatus.OK, "멤버가 추방되었습니다."), + MEMBER_ROLE_UPDATED_SUCCESS(11111, HttpStatus.OK, "멤버 권한이 변경되었습니다."), + CLUB_FIND_SUCCESS(11112, HttpStatus.OK, "동아리 공개 정보를 성공적으로 조회했습니다."), + CLUB_PROFILE_IMAGE_DELETED_SUCCESS(11113, HttpStatus.OK, "동아리 프로필 사진이 삭제되었습니다."), + CLUB_BACKGROUND_IMAGE_DELETED_SUCCESS(11114, HttpStatus.OK, "동아리 배경 사진이 삭제되었습니다."), + MEMBER_APPLY_OB_SUCCESS(11115, HttpStatus.OK, "멤버의 OB 기수 등록이 완료되었습니다."), + MEMBER_CARDINAL_SET_SUCCESS(11116, HttpStatus.CREATED, "활동 기수가 설정되었습니다."), + MEMBER_PROFILE_IMAGE_DELETED_SUCCESS(11117, HttpStatus.OK, "동아리 프로필 사진이 삭제되었습니다."), + MEMBER_PROFILE_UPDATED_SUCCESS(11118, HttpStatus.OK, "프로필이 성공적으로 수정되었습니다."), + LEAD_TRANSFERRED_SUCCESS(11119, HttpStatus.OK, "LEAD 권한이 이양되었습니다."), + MEMBERSHIP_STATUS_FIND_SUCCESS(11120, HttpStatus.OK, "동아리 가입 상태를 성공적으로 조회했습니다."), + MEMBER_SUMMARY_FIND_SUCCESS(11121, HttpStatus.OK, "내 요약 정보를 성공적으로 조회했습니다."), + PROFILE_STATUS_FIND_SUCCESS(11122, HttpStatus.OK, "프로필 완성 상태를 성공적으로 조회했습니다."), + MEMBER_RESTORED_SUCCESS(11123, HttpStatus.OK, "멤버가 복구되었습니다."), + MEMBER_CARDINAL_UPDATED_SUCCESS(11124, HttpStatus.OK, "멤버 기수가 수정되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentSaveRequest.kt b/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentSaveRequest.kt new file mode 100644 index 00000000..e698dccd --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentSaveRequest.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.comment.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class CommentSaveRequest( + @field:Schema(description = "부모 댓글 ID (대댓글인 경우)", example = "1", nullable = true) + val parentCommentId: Long? = null, + @field:Schema(description = "댓글 내용", example = "댓글입니다.") + @field:NotBlank + @field:Size(max = 300, message = "댓글은 최대 300자까지 가능합니다.") + val content: String, + @field:Schema(description = "첨부 파일 목록", nullable = true) + @field:Valid + val files: List<@NotNull FileSaveRequest>? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentUpdateRequest.kt new file mode 100644 index 00000000..4d1e916a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentUpdateRequest.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.comment.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class CommentUpdateRequest( + @field:Schema(description = "댓글 내용", example = "댓글입니다.") + @field:NotBlank + @field:Size(max = 300, message = "댓글은 최대 300자까지 가능합니다.") + val content: String, + @field:Schema( + description = "첨부 파일 변경 규약: null=변경 안 함, []=전체 삭제, 배열 전달=해당 목록으로 교체", + nullable = true, + ) + @field:Valid + val files: List<@NotNull FileSaveRequest>? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt new file mode 100644 index 00000000..4759190e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.comment.application.dto.response + +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.user.application.dto.response.UserInfo +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class CommentResponse( + @field:Schema(description = "댓글 ID", example = "1") + val id: Long, + @field:Schema(description = "작성자 정보") + val author: UserInfo, + @field:Schema(description = "댓글 내용", example = "댓글입니다.") + val content: String, + @field:Schema(description = "작성 시간", example = "2026-02-18T12:00:00") + val time: LocalDateTime, + @field:Schema(description = "첨부 파일 목록") + val fileUrls: List, + @field:Schema(description = "대댓글 목록") + val children: List, +) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentAlreadyDeletedException.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentAlreadyDeletedException.kt new file mode 100644 index 00000000..f318e479 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentAlreadyDeletedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.comment.application.exception + +import com.weeth.global.common.exception.BaseException + +class CommentAlreadyDeletedException : BaseException(CommentErrorCode.COMMENT_ALREADY_DELETED) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt new file mode 100644 index 00000000..0db78c4e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.comment.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class CommentErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("요청한 댓글 ID에 해당하는 댓글이 존재하지 않을 때 발생합니다.") + COMMENT_NOT_FOUND(20500, HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + + @ExplainError("댓글 작성자가 아닌 사용자가 수정/삭제를 시도할 때 발생합니다.") + COMMENT_NOT_OWNED(20501, HttpStatus.FORBIDDEN, "댓글 작성자만 수정/삭제할 수 있습니다."), + + @ExplainError("이미 삭제된 댓글에 대해 삭제를 재시도할 때 발생합니다.") + COMMENT_ALREADY_DELETED(20502, HttpStatus.BAD_REQUEST, "이미 삭제된 댓글입니다."), + + @ExplainError("댓글이 허용되지 않은 게시판의 게시글에 댓글을 작성하려 할 때 발생합니다.") + COMMENT_NOT_ALLOWED(20503, HttpStatus.FORBIDDEN, "댓글이 허용되지 않은 게시판입니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotAllowedException.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotAllowedException.kt new file mode 100644 index 00000000..8efcc103 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotAllowedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.comment.application.exception + +import com.weeth.global.common.exception.BaseException + +class CommentNotAllowedException : BaseException(CommentErrorCode.COMMENT_NOT_ALLOWED) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotFoundException.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotFoundException.kt new file mode 100644 index 00000000..b6866b39 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.comment.application.exception + +import com.weeth.global.common.exception.BaseException + +class CommentNotFoundException : BaseException(CommentErrorCode.COMMENT_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotOwnedException.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotOwnedException.kt new file mode 100644 index 00000000..63483f9b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotOwnedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.comment.application.exception + +import com.weeth.global.common.exception.BaseException + +class CommentNotOwnedException : BaseException(CommentErrorCode.COMMENT_NOT_OWNED) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt new file mode 100644 index 00000000..69712555 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt @@ -0,0 +1,32 @@ +package com.weeth.domain.comment.application.mapper + +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.user.application.dto.response.UserInfo +import org.springframework.stereotype.Component + +@Component +class CommentMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { + fun toCommentDto( + comment: Comment, + children: List, + fileUrls: List, + ): CommentResponse = + CommentResponse( + id = comment.id, + author = + UserInfo.of( + comment.clubMember.user, + comment.clubMember.memberRole, + comment.clubMember.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, + ), + content = comment.content, + time = comment.createdAt, + fileUrls = fileUrls, + children = children, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt new file mode 100644 index 00000000..ffdc7170 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -0,0 +1,170 @@ +package com.weeth.domain.comment.application.usecase.command + +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest +import com.weeth.domain.comment.application.exception.CommentAlreadyDeletedException +import com.weeth.domain.comment.application.exception.CommentNotAllowedException +import com.weeth.domain.comment.application.exception.CommentNotFoundException +import com.weeth.domain.comment.application.exception.CommentNotOwnedException +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageCommentUseCase( + private val commentRepository: CommentRepository, + private val postRepository: PostRepository, // 타 도메인 이므로 Reader 사용 검토 + private val clubMemberPolicy: ClubMemberPolicy, + private val fileReader: FileReader, + private val fileRepository: FileRepository, + private val fileMapper: FileMapper, +) : PostCommentUsecase { + @Transactional + override fun savePostComment( + dto: CommentSaveRequest, + postId: Long, + userId: Long, + ) { + val post = findPostWithLock(postId) + ensureCommentAllowed(post) + + val clubId = post.board.club.id + val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) + val parent = + dto.parentCommentId?.let { parentId -> + commentRepository.findByIdAndPostId(parentId, postId) ?: throw CommentNotFoundException() + } + + val comment = + Comment.createForPost( + content = dto.content, + post = post, + clubMember = clubMember, + parent = parent, + ) + val savedComment = commentRepository.save(comment) + saveCommentFiles(savedComment, dto.files) + post.increaseCommentCount() + } + + @Transactional + override fun updatePostComment( + dto: CommentUpdateRequest, + postId: Long, + commentId: Long, + userId: Long, + ) { + val comment = commentRepository.findByIdAndPostId(commentId, postId) ?: throw CommentNotFoundException() + ensureOwner(comment, userId) + ensureNotDeleted(comment) + + comment.updateContent(dto.content) + replaceCommentFiles(comment, dto.files) + } + + @Transactional + override fun deletePostComment( + postId: Long, + commentId: Long, + userId: Long, + ) { + val post = findPostWithLock(postId) + val comment = commentRepository.findByIdAndPostId(commentId, postId) ?: throw CommentNotFoundException() + ensureOwner(comment, userId) + + deleteComment(comment) + post.decreaseCommentCount() + } + + private fun saveCommentFiles( + comment: Comment, + files: List?, + ) { + val mappedFiles = fileMapper.toFileList(files, FileOwnerType.COMMENT, comment.id) + if (mappedFiles.isNotEmpty()) { + fileRepository.saveAll(mappedFiles) + } + } + + private fun replaceCommentFiles( + comment: Comment, + files: List?, + ) { + if (files == null) { + return + } + + deleteCommentFiles(comment.id) + saveCommentFiles(comment, files) + } + + private fun deleteComment(comment: Comment) { + if (comment.isDeleted) { + throw CommentAlreadyDeletedException() + } + + if (comment.children.isEmpty()) { + deleteCommentFiles(comment) + val parent = comment.parent + val shouldDeleteParent = parent?.let { it.isDeleted && it.children.size == 1 } == true + commentRepository.delete(comment) + + if (shouldDeleteParent) { + parent.let { + deleteCommentFiles(it) + commentRepository.delete(it) + } + } + return + } + + deleteCommentFiles(comment) + comment.markAsDeleted() + } + + private fun deleteCommentFiles(comment: Comment) { + deleteCommentFiles(comment.id) + } + + private fun deleteCommentFiles(commentId: Long) { + val files = fileReader.findAll(FileOwnerType.COMMENT, commentId) + + if (files.isNotEmpty()) { + fileRepository.deleteAll(files) + } + } + + private fun ensureOwner( + comment: Comment, + userId: Long, + ) { + if (!comment.isOwnedBy(userId)) { + throw CommentNotOwnedException() + } + } + + private fun ensureNotDeleted(comment: Comment) { + if (comment.isDeleted) { + throw CommentAlreadyDeletedException() + } + } + + private fun ensureCommentAllowed(post: Post) { + if (!post.board.isCommentEnabled) { + throw CommentNotAllowedException() + } + } + + private fun findPostWithLock(postId: Long): Post = + postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/PostCommentUsecase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/PostCommentUsecase.kt new file mode 100644 index 00000000..22e495ec --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/PostCommentUsecase.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.comment.application.usecase.command + +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest + +/** + * Todo: Notice가 제거됨에 따라 인터페이스 분리가 필요 없음. 제거 검토 + */ +interface PostCommentUsecase { + fun savePostComment( + dto: CommentSaveRequest, + postId: Long, + userId: Long, + ) + + fun updatePostComment( + dto: CommentUpdateRequest, + postId: Long, + commentId: Long, + userId: Long, + ) + + fun deletePostComment( + postId: Long, + commentId: Long, + userId: Long, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt new file mode 100644 index 00000000..1c698b91 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt @@ -0,0 +1,61 @@ +package com.weeth.domain.comment.application.usecase.query + +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.comment.application.mapper.CommentMapper +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetCommentQueryService( + private val fileReader: FileReader, + private val fileMapper: FileMapper, + private val commentMapper: CommentMapper, +) { + /** + * Comment 리스트를 받아 자식, 부모 관계 트리를 형성하는 메서드 + */ + fun toCommentTreeResponses(comments: List): List { + if (comments.isEmpty()) { + return emptyList() + } + + val commentIds: List = comments.map { it.id } + val filesByCommentId: Map> = + fileReader + .findAll(FileOwnerType.COMMENT, commentIds) + .groupBy { it.ownerId } + + val childrenByParentId: Map> = + comments + .filter { it.parent != null } + .groupBy { requireNotNull(it.parent).id } + + return comments + .filter { it.parent == null } + .map { mapToCommentResponse(it, childrenByParentId, filesByCommentId) } + } + + private fun mapToCommentResponse( + comment: Comment, + childrenByParentId: Map>, + filesByCommentId: Map>, + ): CommentResponse { + val children = + childrenByParentId[comment.id] + ?.map { mapToCommentResponse(it, childrenByParentId, filesByCommentId) } + ?: emptyList() + + val files = + filesByCommentId[comment.id] + ?.map(fileMapper::toFileResponse) + ?: emptyList() + + return commentMapper.toCommentDto(comment, children, files) + } +} diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt b/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt new file mode 100644 index 00000000..2dbcef75 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt @@ -0,0 +1,75 @@ +package com.weeth.domain.comment.domain.entity + +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.comment.domain.vo.CommentContent +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.OneToMany +import jakarta.persistence.Table + +@Entity +@Table(name = "comment") +class Comment( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + val id: Long = 0, + @Column(length = 300, nullable = false) + var content: String, + @Column(nullable = false) + var isDeleted: Boolean = false, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + val post: Post, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_member_id") + val clubMember: ClubMember, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + val parent: Comment? = null, + @OneToMany(mappedBy = "parent", cascade = [CascadeType.REMOVE], fetch = FetchType.LAZY) + val children: MutableList = mutableListOf(), +) : BaseEntity() { + fun markAsDeleted() { + isDeleted = true + content = DELETED_CONTENT + } + + fun getIsDeleted(): Boolean = isDeleted + + fun updateContent(newContent: String) { + content = CommentContent.from(newContent).value + } + + fun isOwnedBy(userId: Long): Boolean = clubMember.user.id == userId + + companion object { + private const val DELETED_CONTENT = "삭제된 댓글입니다." + + fun createForPost( + content: String, + post: Post, + clubMember: ClubMember, + parent: Comment?, + ): Comment { + require(parent == null || parent.post.id == post.id) { + "부모 댓글은 동일한 게시글에 존재해야 합니다." + } + return Comment( + content = CommentContent.from(content).value, + post = post, + clubMember = clubMember, + parent = parent, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentReader.kt b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentReader.kt new file mode 100644 index 00000000..81dc6a10 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentReader.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.comment.domain.repository + +import com.weeth.domain.comment.domain.entity.Comment + +interface CommentReader { + fun findAllByPostId(postId: Long): List +} diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt new file mode 100644 index 00000000..b511b037 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.comment.domain.repository + +import com.weeth.domain.comment.domain.entity.Comment +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param + +interface CommentRepository : + JpaRepository, + CommentReader { + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) + fun findByIdAndPostId( + id: Long, + postId: Long, + ): Comment? + + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) + @Query("SELECT c FROM Comment c WHERE c.post.id = :postId") + override fun findAllByPostId( + @Param("postId") postId: Long, + ): List +} diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/vo/CommentContent.kt b/src/main/kotlin/com/weeth/domain/comment/domain/vo/CommentContent.kt new file mode 100644 index 00000000..001e926d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/domain/vo/CommentContent.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.comment.domain.vo + +@JvmInline +value class CommentContent private constructor( + val value: String, +) { + companion object { + private const val MAX_LENGTH = 300 + + fun from(raw: String): CommentContent { + require(raw.isNotBlank()) { "댓글 내용은 빈 값이 될 수 없습니다." } + require(raw.length <= MAX_LENGTH) { + "댓글 내용은 ${MAX_LENGTH}자 이하로 입력해주세요." + } + return CommentContent(raw) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt b/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt new file mode 100644 index 00000000..15706d08 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.comment.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class CommentResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + POST_COMMENT_CREATED_SUCCESS(10500, HttpStatus.OK, "게시글 댓글이 성공적으로 생성되었습니다."), + POST_COMMENT_UPDATED_SUCCESS(10501, HttpStatus.OK, "게시글 댓글이 성공적으로 수정되었습니다."), + POST_COMMENT_DELETED_SUCCESS(10502, HttpStatus.OK, "게시글 댓글이 성공적으로 삭제되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt b/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt new file mode 100644 index 00000000..0c138560 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt @@ -0,0 +1,65 @@ +package com.weeth.domain.comment.presentation + +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest +import com.weeth.domain.comment.application.exception.CommentErrorCode +import com.weeth.domain.comment.application.usecase.command.PostCommentUsecase +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "COMMENT-POST", description = "게시글 댓글 API") +@RestController +@RequestMapping("/api/v1/posts/{postId}/comments") +@ApiErrorCodeExample(CommentErrorCode::class) +class PostCommentController( + private val postCommentUsecase: PostCommentUsecase, +) { + @PostMapping + @Operation(summary = "게시글 댓글 작성") + fun savePostComment( + @RequestBody @Valid dto: CommentSaveRequest, + @PathVariable postId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + postCommentUsecase.savePostComment(dto, postId, userId) + return CommonResponse.success(CommentResponseCode.POST_COMMENT_CREATED_SUCCESS) + } + + @PatchMapping("/{commentId}") + @Operation( + summary = "게시글 댓글 수정", + description = "files 규약: null=기존 첨부 유지, []=기존 첨부 전체 삭제, 배열 전달=전달 목록으로 교체", + ) + fun updatePostComment( + @RequestBody @Valid dto: CommentUpdateRequest, + @PathVariable postId: Long, + @PathVariable commentId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + postCommentUsecase.updatePostComment(dto, postId, commentId, userId) + return CommonResponse.success(CommentResponseCode.POST_COMMENT_UPDATED_SUCCESS) + } + + @DeleteMapping("/{commentId}") + @Operation(summary = "게시글 댓글 삭제") + fun deletePostComment( + @PathVariable postId: Long, + @PathVariable commentId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + postCommentUsecase.deletePostComment(postId, commentId, userId) + return CommonResponse.success(CommentResponseCode.POST_COMMENT_DELETED_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardClubInfoResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardClubInfoResponse.kt new file mode 100644 index 00000000..bf0a7519 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardClubInfoResponse.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.dashboard.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class DashboardClubInfoResponse( + @field:Schema(description = "동아리 ID (Base62)", example = "1A2b3C") + val id: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val name: String, + @field:Schema(description = "초대 코드", example = "550e8400-e29b-41d4-a716-446655440000") + val code: String, + @field:Schema(description = "학교 이름", example = "가천대학교") + val schoolName: String, + @field:Schema(description = "동아리 설명", example = "IT 동아리") + val description: String?, + @field:Schema(description = "활성 멤버 수", example = "70") + val memberCount: Long, + @field:Schema(description = "프로필 이미지 URL") + val profileImageUrl: String?, + @field:Schema(description = "배경 이미지 URL") + val backgroundImageUrl: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardHomeResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardHomeResponse.kt new file mode 100644 index 00000000..1ae57883 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardHomeResponse.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.dashboard.application.dto.response + +import com.fasterxml.jackson.annotation.JsonIgnore +import io.swagger.v3.oas.annotations.media.Schema + +data class DashboardHomeResponse( + @field:Schema(description = "현재 동아리 정보") + val club: DashboardClubInfoResponse, + @field:Schema(description = "내 활동 정보") + val myInfo: DashboardMyInfoResponse, + // MVP 제외 (이후 개발 시 @field:Schema(description = "오늘의 일정") 추가) + @JsonIgnore + val todaySchedules: List, + // MVP 제외 (이후 개발 시 @field:Schema(description = "가입한 동아리 목록") 추가) + @JsonIgnore + val myClubs: List, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyClubResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyClubResponse.kt new file mode 100644 index 00000000..9d0fad29 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyClubResponse.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.dashboard.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class DashboardMyClubResponse( + @field:Schema(description = "동아리 ID (Base62)", example = "1A2b3C") + val id: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val name: String, + @field:Schema(description = "학교 이름", example = "가천대학교") + val schoolName: String, + @field:Schema(description = "동아리 설명", example = "IT 동아리") + val description: String?, + @field:Schema(description = "프로필 이미지 URL") + val profileImageUrl: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt new file mode 100644 index 00000000..f7a70769 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt @@ -0,0 +1,11 @@ +package com.weeth.domain.dashboard.application.dto.response + +import com.weeth.domain.user.application.dto.response.UserInfo +import io.swagger.v3.oas.annotations.media.Schema + +data class DashboardMyInfoResponse( + @field:Schema(description = "사용자 정보") + val userInfo: UserInfo, + @field:Schema(description = "자기소개") + val bio: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardNoticeResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardNoticeResponse.kt new file mode 100644 index 00000000..3dbb0eb1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardNoticeResponse.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.dashboard.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class DashboardNoticeResponse( + @field:Schema(description = "게시글 ID", example = "1") + val id: Long, + @field:Schema(description = "게시판 ID", example = "1") + val boardId: Long, + @field:Schema(description = "공지 제목", example = "중간고사 기간 공지") + val title: String, + @field:Schema(description = "공지 내용", example = "이번 주 정기 모임은 중간고사 기간으로 인해 쉬어갑니다.") + val content: String, + @field:Schema(description = "최종 수정 일시") + val time: LocalDateTime, + @field:Schema(description = "24시간 내 새 공지 여부", example = "true") + val isNew: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt new file mode 100644 index 00000000..a8447d61 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt @@ -0,0 +1,33 @@ +package com.weeth.domain.dashboard.application.dto.response + +import com.weeth.domain.board.application.dto.response.BoardConfigResponse +import com.weeth.domain.board.application.dto.response.PostLikeResponse +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.user.application.dto.response.UserInfo +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class DashboardPostResponse( + @field:Schema(description = "게시글 ID", example = "1") + val id: Long, + @field:Schema(description = "게시판 ID", example = "1") + val boardId: Long, + @field:Schema(description = "작성자 정보") + val author: UserInfo, + @field:Schema(description = "제목", example = "안녕하세요") + val title: String, + @field:Schema(description = "내용", example = "오늘은 날씨가 좋네요") + val content: String, + @field:Schema(description = "작성일") + val time: LocalDateTime, + @field:Schema(description = "댓글 수", example = "5") + val commentCount: Int, + @field:Schema(description = "좋아요 정보") + val like: PostLikeResponse, + @field:Schema(description = "첨부 파일 목록") + val fileUrls: List, + @field:Schema(description = "24시간 내 새 게시글 여부", example = "true") + val isNew: Boolean, + @field:Schema(description = "게시판 설정") + val boardConfig: BoardConfigResponse, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardScheduleResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardScheduleResponse.kt new file mode 100644 index 00000000..74f6d7d0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardScheduleResponse.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.dashboard.application.dto.response + +import com.weeth.domain.dashboard.domain.enums.ScheduleType +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class DashboardScheduleResponse( + @field:Schema(description = "일정 ID", example = "1") + val id: Long, + @field:Schema(description = "일정 제목", example = "Spring 스터디") + val title: String, + @field:Schema(description = "시작 일시", example = "2026-03-09T14:00:00") + val start: LocalDateTime, + @field:Schema(description = "종료 일시", example = "2026-03-09T16:00:00") + val end: LocalDateTime, + @field:Schema(description = "일정 유형", example = "EVENT") + val type: ScheduleType, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardUnreadNoticeResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardUnreadNoticeResponse.kt new file mode 100644 index 00000000..23bbf59c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardUnreadNoticeResponse.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.dashboard.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class DashboardUnreadNoticeResponse( + @field:Schema(description = "게시글 ID", example = "1") + val id: Long, + @field:Schema(description = "게시판 ID", example = "1") + val boardId: Long, + @field:Schema(description = "공지 제목", example = "중간고사 기간 공지") + val title: String, + @field:Schema(description = "공지 내용", example = "이번 주 정기 모임은 중간고사 기간으로 인해 쉬어갑니다.") + val content: String, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardErrorCode.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardErrorCode.kt new file mode 100644 index 00000000..48909f8a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardErrorCode.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.dashboard.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class DashboardErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("사용자가 해당 동아리의 활성 멤버가 아닐 때 발생합니다.") + NOT_CLUB_MEMBER(21200, HttpStatus.FORBIDDEN, "해당 동아리의 멤버가 아닙니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardNotClubMemberException.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardNotClubMemberException.kt new file mode 100644 index 00000000..1e6ebb72 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardNotClubMemberException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.dashboard.application.exception + +import com.weeth.global.common.exception.BaseException + +class DashboardNotClubMemberException : BaseException(DashboardErrorCode.NOT_CLUB_MEMBER) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt new file mode 100644 index 00000000..7c2e4693 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt @@ -0,0 +1,147 @@ +package com.weeth.domain.dashboard.application.mapper + +import com.weeth.domain.board.application.dto.response.BoardConfigResponse +import com.weeth.domain.board.application.dto.response.PostLikeResponse +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.dashboard.application.dto.response.DashboardClubInfoResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardHomeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardMyClubResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardMyInfoResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardNoticeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardPostResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardScheduleResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardUnreadNoticeResponse +import com.weeth.domain.dashboard.domain.enums.ScheduleType +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.schedule.domain.entity.Event +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.user.application.dto.response.UserInfo +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.id.TsidBase62Encoder +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class DashboardMapper( + private val fileMapper: FileMapper, + private val fileAccessUrlPort: FileAccessUrlPort, +) { + fun toClubInfoResponse( + club: Club, + memberCount: Long, + ) = DashboardClubInfoResponse( + id = TsidBase62Encoder.encode(club.id), + name = club.name, + schoolName = club.schoolName, + description = club.description, + memberCount = memberCount, + profileImageUrl = resolveClubImage(club.profileImageStorageKey), + backgroundImageUrl = resolveClubImage(club.backgroundImageStorageKey), + code = club.code, + ) + + fun toMyInfoResponse( + user: User, + clubMember: ClubMember, + ) = DashboardMyInfoResponse( + userInfo = UserInfo.of(user, clubMember.memberRole, resolveProfileImage(clubMember)), + bio = clubMember.bio, + ) + + fun toHomeResponse( + club: Club, + memberCount: Long, + myInfo: DashboardMyInfoResponse, + todaySchedules: List, + myClubs: List, + ) = DashboardHomeResponse( + club = toClubInfoResponse(club, memberCount), + myInfo = myInfo, + todaySchedules = todaySchedules, + myClubs = myClubs, + ) + + fun toMyClubResponse(cm: ClubMember) = + DashboardMyClubResponse( + id = TsidBase62Encoder.encode(cm.club.id), + name = cm.club.name, + schoolName = cm.club.schoolName, + description = cm.club.description, + profileImageUrl = resolveClubImage(cm.club.profileImageStorageKey), + ) + + fun toScheduleResponses( + events: List, + sessions: List, + ): List = + (events.map(::toScheduleResponse) + sessions.map(::toScheduleResponse)) + .sortedBy { it.start } + + fun toScheduleResponse(event: Event) = + DashboardScheduleResponse( + id = event.id, + title = event.title, + start = event.start, + end = event.end, + type = ScheduleType.EVENT, + ) + + fun toScheduleResponse(session: Session) = + DashboardScheduleResponse( + id = session.id, + title = session.title, + start = session.start, + end = session.end, + type = ScheduleType.SESSION, + ) + + fun toPostResponse( + post: Post, + files: List, + now: LocalDateTime, + isLiked: Boolean, + memberRole: MemberRole, + ) = DashboardPostResponse( + id = post.id, + boardId = post.board.id, + author = UserInfo.of(post.clubMember.user, post.clubMember.memberRole, resolveProfileImage(post.clubMember)), + title = post.title, + content = post.content, + time = post.createdAt, + commentCount = post.commentCount, + like = PostLikeResponse(isLiked = isLiked, likeCount = post.likeCount), + fileUrls = files.map(fileMapper::toFileResponse), + isNew = post.createdAt.isAfter(now.minusHours(24)), + boardConfig = BoardConfigResponse.of(post.board, memberRole), + ) + + fun toNoticeResponse( + post: Post, + now: LocalDateTime, + ) = DashboardNoticeResponse( + id = post.id, + boardId = post.board.id, + title = post.title, + content = post.content, + time = post.createdAt, + isNew = post.createdAt.isAfter(now.minusHours(24)), + ) + + fun toUnreadNoticeResponse(post: Post) = + DashboardUnreadNoticeResponse( + id = post.id, + boardId = post.board.id, + title = post.title, + content = post.content, + ) + + private fun resolveProfileImage(member: ClubMember): String? = + member.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) } + + private fun resolveClubImage(storageKey: String?): String? = storageKey?.let { fileAccessUrlPort.resolve(it) } +} diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt new file mode 100644 index 00000000..b2310e4f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt @@ -0,0 +1,149 @@ +package com.weeth.domain.dashboard.application.usecase.query + +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardReader +import com.weeth.domain.board.domain.repository.PostLikeReader +import com.weeth.domain.board.domain.repository.PostReader +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.dashboard.application.dto.response.DashboardHomeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardNoticeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardPostResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardScheduleResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardUnreadNoticeResponse +import com.weeth.domain.dashboard.application.mapper.DashboardMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.schedule.domain.repository.EventReader +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Slice +import org.springframework.data.domain.SliceImpl +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class GetDashboardQueryService( + private val boardReader: BoardReader, + private val postLikeReader: PostLikeReader, + private val clubReader: ClubReader, + private val clubMemberReader: ClubMemberReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val eventReader: EventReader, + private val sessionReader: SessionReader, + private val postReader: PostReader, + private val fileReader: FileReader, + private val userReader: UserReader, + private val dashboardMapper: DashboardMapper, +) { + fun getHome( + clubId: Long, + userId: Long, + ): DashboardHomeResponse { + val myMember = clubMemberPolicy.getActiveMember(clubId, userId) + + val club = clubReader.getClubById(clubId) + val memberCount = clubMemberReader.countActiveByClubId(clubId) + + val todayStart = LocalDate.now().atStartOfDay() + val todayEnd = todayStart.plusDays(1).minusNanos(1) + val todayEvents = eventReader.findByClubIdAndDateRange(clubId, todayStart, todayEnd) + val todaySessions = sessionReader.findAllByClubIdAndStartBetween(clubId, todayStart, todayEnd) + + val myClubs = clubMemberReader.findActiveByUserId(userId).map(dashboardMapper::toMyClubResponse) + val myInfo = dashboardMapper.toMyInfoResponse(userReader.getById(userId), myMember) + + return dashboardMapper.toHomeResponse( + club = club, + memberCount = memberCount, + myInfo = myInfo, + todaySchedules = dashboardMapper.toScheduleResponses(todayEvents, todaySessions), + myClubs = myClubs, + ) + } + + fun getRecentPosts( + clubId: Long, + userId: Long, + pageNumber: Int, + pageSize: Int, + ): Slice { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + + val accessibleBoardIds = + boardReader + .findAllActiveByClubId(clubId) + .filter { it.isAccessibleBy(member.memberRole) && it.type != BoardType.NOTICE } + .map { it.id } + + val pageable = PageRequest.of(pageNumber, pageSize) + + if (accessibleBoardIds.isEmpty()) { + return SliceImpl(emptyList(), pageable, false) + } + + val posts = postReader.findRecentByBoardIds(accessibleBoardIds, pageable) + val now = LocalDateTime.now() + val postIds = posts.content.map { it.id } + + if (postIds.isEmpty()) return SliceImpl(emptyList(), pageable, false) + + val filesByPostId = fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } + val likedPostIds = postLikeReader.findLikedPostIds(postIds, userId) + + return posts.map { post -> + dashboardMapper.toPostResponse( + post = post, + files = filesByPostId[post.id] ?: emptyList(), + now = now, + isLiked = post.id in likedPostIds, + memberRole = member.memberRole, + ) + } + } + + fun getRecentNotices( + clubId: Long, + userId: Long, + size: Int, + ): List { + clubMemberPolicy.getActiveMember(clubId, userId) + + val notices = postReader.findRecentByClubIdAndBoardType(clubId, BoardType.NOTICE, PageRequest.of(0, size)) + val now = LocalDateTime.now() + + return notices.content.map { dashboardMapper.toNoticeResponse(it, now) } + } + + fun getMonthlySchedules( + clubId: Long, + userId: Long, + ): List { + clubMemberPolicy.getActiveMember(clubId, userId) + + val monthStart = LocalDate.now().withDayOfMonth(1).atStartOfDay() + val monthEnd = monthStart.plusMonths(1).minusNanos(1) + + val events = eventReader.findByClubIdAndDateRange(clubId, monthStart, monthEnd) + val sessions = sessionReader.findAllByClubIdAndStartBetween(clubId, monthStart, monthEnd) + + return dashboardMapper.toScheduleResponses(events, sessions) + } + + fun getUnreadNotice( + clubId: Long, + userId: Long, + ): DashboardUnreadNoticeResponse? { + clubMemberPolicy.getActiveMember(clubId, userId) + + val since = LocalDateTime.now().minusWeeks(2) + return postReader + .findFirstUnreadNoticeSince(clubId, userId, BoardType.NOTICE, since) + ?.let(dashboardMapper::toUnreadNoticeResponse) + } +} diff --git a/src/main/kotlin/com/weeth/domain/dashboard/domain/enums/ScheduleType.kt b/src/main/kotlin/com/weeth/domain/dashboard/domain/enums/ScheduleType.kt new file mode 100644 index 00000000..262ad4aa --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/domain/enums/ScheduleType.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.dashboard.domain.enums + +enum class ScheduleType { + SESSION, + EVENT, +} diff --git a/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardController.kt b/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardController.kt new file mode 100644 index 00000000..0d2ba989 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardController.kt @@ -0,0 +1,95 @@ +package com.weeth.domain.dashboard.presentation + +import com.weeth.domain.club.application.exception.ClubErrorCode +import com.weeth.domain.dashboard.application.dto.response.DashboardHomeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardNoticeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardPostResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardScheduleResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardUnreadNoticeResponse +import com.weeth.domain.dashboard.application.exception.DashboardErrorCode +import com.weeth.domain.dashboard.application.usecase.query.GetDashboardQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.data.domain.Slice +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "DASHBOARD", description = "대시보드 API") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/dashboard") +@ApiErrorCodeExample(DashboardErrorCode::class, ClubErrorCode::class, JwtErrorCode::class) +class DashboardController( + private val getDashboardQueryService: GetDashboardQueryService, +) { + @GetMapping("/home") + @Operation(summary = "홈 조회") + fun getHome( + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + DashboardResponseCode.DASHBOARD_HOME_SUCCESS, + getDashboardQueryService.getHome(clubId, userId), + ) + + @GetMapping("/recent-posts") + @Operation(summary = "최신 게시글 조회") + fun getRecentPosts( + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @RequestParam(defaultValue = "0") pageNumber: Int, + @RequestParam(defaultValue = "10") pageSize: Int, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + DashboardResponseCode.DASHBOARD_RECENT_POSTS_SUCCESS, + getDashboardQueryService.getRecentPosts(clubId, userId, pageNumber, pageSize), + ) + + @GetMapping("/recent-notices") + @Operation(summary = "최신 공지 조회") + fun getRecentNotices( + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @RequestParam(defaultValue = "5") size: Int, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + DashboardResponseCode.DASHBOARD_RECENT_NOTICES_SUCCESS, + getDashboardQueryService.getRecentNotices(clubId, userId, size), + ) + + @GetMapping("/monthly-schedules") + @Operation(summary = "월간 일정 조회") + fun getMonthlySchedules( + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + DashboardResponseCode.DASHBOARD_MONTHLY_SCHEDULES_SUCCESS, + getDashboardQueryService.getMonthlySchedules(clubId, userId), + ) + + @GetMapping("/unread-notice") + @Operation(summary = "2주 이내 읽지 않은 공지 조회") + fun getUnreadNotice( + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + DashboardResponseCode.DASHBOARD_UNREAD_NOTICE_SUCCESS, + getDashboardQueryService.getUnreadNotice(clubId, userId), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardResponseCode.kt b/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardResponseCode.kt new file mode 100644 index 00000000..de2164a6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardResponseCode.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.dashboard.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class DashboardResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + DASHBOARD_HOME_SUCCESS(11200, HttpStatus.OK, "홈 정보를 성공적으로 조회했습니다."), + DASHBOARD_RECENT_POSTS_SUCCESS(11201, HttpStatus.OK, "최신 게시글을 성공적으로 조회했습니다."), + DASHBOARD_RECENT_NOTICES_SUCCESS(11202, HttpStatus.OK, "최신 공지를 성공적으로 조회했습니다."), + DASHBOARD_MONTHLY_SCHEDULES_SUCCESS(11203, HttpStatus.OK, "월간 일정을 성공적으로 조회했습니다."), + DASHBOARD_UNREAD_NOTICE_SUCCESS(11204, HttpStatus.OK, "읽지 않은 공지를 성공적으로 조회했습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt b/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt new file mode 100644 index 00000000..93cf515a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.file.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Positive + +data class FileSaveRequest( + @field:Schema(description = "원본 파일명", example = "profile-image.png") + @field:NotBlank + val fileName: String, + @field:Schema( + description = "저장소 키. `Type/YY-MM/UUID_원본파일명` 형식", + example = "POST/2026-02/2c0a4d45-ec94-4ec0-85e1-b489c2eaf9c3_profile-image.png", + ) + @field:NotBlank + val storageKey: String, + @field:Schema(description = "파일 크기(bytes)", example = "102400") + @field:Positive + val fileSize: Long, + @field:Schema(description = "파일 Content-Type. `image/png, image/jpeg, application/pdf` 지원", example = "image/png") + @field:NotBlank + val contentType: String, +) diff --git a/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt b/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt new file mode 100644 index 00000000..eecc14e3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.file.application.dto.response + +import com.weeth.domain.file.domain.enums.FileStatus +import io.swagger.v3.oas.annotations.media.Schema + +data class FileResponse( + @field:Schema(description = "파일 ID", example = "1") + val fileId: Long, + @field:Schema(description = "원본 파일명", example = "profile-image.png") + val fileName: String, + @field:Schema( + description = "조회용 파일 URL", + example = "https://bucket.s3.ap-northeast-2.amazonaws.com/POST/2026-02/uuid_profile-image.png", + ) + val fileUrl: String, + @field:Schema(description = "저장소 키", example = "POST/2026-02/uuid_profile-image.png") + val storageKey: String, + @field:Schema(description = "파일 크기(bytes)", example = "102400") + val fileSize: Long, + @field:Schema(description = "파일 Content-Type", example = "image/png") + val contentType: String, + @field:Schema(description = "파일 상태", example = "UPLOADED") + val status: FileStatus, +) diff --git a/src/main/kotlin/com/weeth/domain/file/application/dto/response/UrlResponse.kt b/src/main/kotlin/com/weeth/domain/file/application/dto/response/UrlResponse.kt new file mode 100644 index 00000000..cf4fcd53 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/dto/response/UrlResponse.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.file.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class UrlResponse( + @field:Schema(description = "원본 파일명", example = "profile-image.png") + val fileName: String, + @field:Schema( + description = "Presigned PUT URL", + example = "https://bucket.s3.amazonaws.com/TEMP/2026-02/uuid_profile-image.png", + ) + val putUrl: String, + @field:Schema(description = "저장소 키", example = "TEMP/2026-02/uuid_profile-image.png") + val storageKey: String, +) diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt new file mode 100644 index 00000000..35fce693 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.file.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class FileErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("파일 ID로 조회했으나 해당 파일이 존재하지 않을 때 발생합니다.") + FILE_NOT_FOUND(20600, HttpStatus.NOT_FOUND, "존재하지 않는 파일입니다."), + + @ExplainError("Presigned URL 생성 중 S3 연결 오류가 발생했을 때 발생합니다.") + PRESIGNED_URL_GENERATION_FAILED(30600, HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 URL 생성에 실패했습니다."), + + @ExplainError("허용되지 않은 Content-Type으로 파일 업로드를 시도했을 때 발생합니다.") + UNSUPPORTED_CONTENT_TYPE(20601, HttpStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."), + + @ExplainError("허용되지 않은 확장자로 파일 업로드를 시도했을 때 발생합니다.") + UNSUPPORTED_FILE_EXTENSION(20602, HttpStatus.BAD_REQUEST, "지원하지 않는 파일 확장자입니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/FileNotFoundException.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/FileNotFoundException.kt new file mode 100644 index 00000000..7579f51f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/FileNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.file.application.exception + +import com.weeth.global.common.exception.BaseException + +class FileNotFoundException : BaseException(FileErrorCode.FILE_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/PresignedUrlGenerationException.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/PresignedUrlGenerationException.kt new file mode 100644 index 00000000..db32ce85 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/PresignedUrlGenerationException.kt @@ -0,0 +1,11 @@ +package com.weeth.domain.file.application.exception + +import com.weeth.global.common.exception.BaseException + +class PresignedUrlGenerationException( + cause: Throwable? = null, +) : BaseException(FileErrorCode.PRESIGNED_URL_GENERATION_FAILED) { + init { + initCause(cause) + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileContentTypeException.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileContentTypeException.kt new file mode 100644 index 00000000..6d72f025 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileContentTypeException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.file.application.exception + +import com.weeth.global.common.exception.BaseException + +class UnsupportedFileContentTypeException : BaseException(FileErrorCode.UNSUPPORTED_CONTENT_TYPE) diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileExtensionException.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileExtensionException.kt new file mode 100644 index 00000000..6aca29be --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileExtensionException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.file.application.exception + +import com.weeth.global.common.exception.BaseException + +class UnsupportedFileExtensionException : BaseException(FileErrorCode.UNSUPPORTED_FILE_EXTENSION) diff --git a/src/main/kotlin/com/weeth/domain/file/application/mapper/FileMapper.kt b/src/main/kotlin/com/weeth/domain/file/application/mapper/FileMapper.kt new file mode 100644 index 00000000..4f962597 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/mapper/FileMapper.kt @@ -0,0 +1,52 @@ +package com.weeth.domain.file.application.mapper + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.application.dto.response.UrlResponse +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import org.springframework.stereotype.Component + +@Component +class FileMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { + fun toFileList( + requests: List?, + ownerType: FileOwnerType, + ownerId: Long, + ): List { + if (requests.isNullOrEmpty()) { + return emptyList() + } + + return requests.map { + File.createUploaded( + fileName = it.fileName, + storageKey = it.storageKey, + fileSize = it.fileSize, + contentType = it.contentType, + ownerType = ownerType, + ownerId = ownerId, + ) + } + } + + fun toFileResponse(file: File) = + FileResponse( + fileId = file.id, + fileName = file.fileName, + fileUrl = fileAccessUrlPort.resolve(file.storageKey.value), + storageKey = file.storageKey.value, + fileSize = file.fileSize, + contentType = file.contentType.value, + status = file.status, + ) + + fun toUrlResponse( + fileName: String, + putUrl: String, + storageKey: String, + ) = UrlResponse(fileName = fileName, putUrl = putUrl, storageKey = storageKey) +} diff --git a/src/main/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecase.kt b/src/main/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecase.kt new file mode 100644 index 00000000..f4a7ab06 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecase.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.file.application.usecase.command + +import com.weeth.domain.file.application.dto.response.UrlResponse +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.port.FileUploadUrlPort +import org.springframework.stereotype.Service + +@Service +class GenerateFileUrlUsecase( + private val fileUploadUrlPort: FileUploadUrlPort, + private val fileMapper: FileMapper, +) { + fun generateFileUploadUrls( + ownerType: FileOwnerType, + fileNames: List, + ): List = + fileNames + .map { fileUploadUrlPort.generateUploadUrl(ownerType, it) } + .map { fileMapper.toUrlResponse(it.fileName, it.url, it.storageKey) } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt b/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt new file mode 100644 index 00000000..d1fd0a67 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt @@ -0,0 +1,72 @@ +package com.weeth.domain.file.domain.entity + +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.vo.FileContentType +import com.weeth.domain.file.domain.vo.StorageKey +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.Table + +@Entity +@Table( + name = "file", + indexes = [ + Index(name = "idx_file_owner_type_owner_id", columnList = "owner_type, owner_id"), + ], +) +class File( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + val id: Long = 0, + @Column(nullable = false) + var fileName: String, + @Column(nullable = false, length = 500, unique = true) + val storageKey: StorageKey, + @Column(nullable = false) + val fileSize: Long, + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + val ownerType: FileOwnerType, + @Column(nullable = false) + val ownerId: Long, + @Column(nullable = false, length = 100) + val contentType: FileContentType, + // TODO: 하드 딜리트로 전환 완료되어 더 이상 사용되지 않음. DB 마이그레이션 후 status 컬럼 및 FileStatus enum 제거 예정 + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var status: FileStatus = FileStatus.UPLOADED, +) : BaseEntity() { + companion object { + fun createUploaded( + fileName: String, + storageKey: String, + fileSize: Long, + contentType: String, + ownerType: FileOwnerType, + ownerId: Long, + ): File { + require(fileName.isNotBlank()) { "fileName은 비어 있을 수 없습니다." } + require(fileSize > 0) { "fileSize는 0보다 커야 합니다." } + require(ownerId > 0) { "ownerId는 0보다 커야 합니다." } + + return File( + fileName = fileName, + storageKey = StorageKey(storageKey), + fileSize = fileSize, + contentType = FileContentType(contentType), + ownerType = ownerType, + ownerId = ownerId, + status = FileStatus.UPLOADED, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt new file mode 100644 index 00000000..6d515432 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.file.domain.enums + +enum class FileOwnerType { + POST, + COMMENT, + RECEIPT, + CLUB_MEMBER_PROFILE, + CLUB_PROFILE, + CLUB_BACKGROUND, +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/enums/FileStatus.kt b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileStatus.kt new file mode 100644 index 00000000..80652edd --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileStatus.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.file.domain.enums + +enum class FileStatus { + UPLOADED, + DELETED, +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/port/FileAccessUrlPort.kt b/src/main/kotlin/com/weeth/domain/file/domain/port/FileAccessUrlPort.kt new file mode 100644 index 00000000..ca1bba30 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/port/FileAccessUrlPort.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.file.domain.port + +/** + * 저장된 storageKey를 조회 가능한 URL로 변환하는 포트입니다. + */ +interface FileAccessUrlPort { + /** + * storageKey를 조회용 URL로 변환합니다. + * 기본 구현은 S3 공개 URL을 사용하고, 설정에 따라 CDN URL로 교체될 수 있습니다. + */ + fun resolve(storageKey: String): String +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/port/FileUploadUrlPort.kt b/src/main/kotlin/com/weeth/domain/file/domain/port/FileUploadUrlPort.kt new file mode 100644 index 00000000..d3f93d9b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/port/FileUploadUrlPort.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.file.domain.port + +import com.weeth.domain.file.domain.enums.FileOwnerType + +/** [FileUploadUrlPort.generateUploadUrl] 반환 타입 */ +data class FileUploadUrl( + val fileName: String, + val storageKey: String, + val url: String, +) + +interface FileUploadUrlPort { + fun generateUploadUrl( + ownerType: FileOwnerType, + fileName: String, + ): FileUploadUrl +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt new file mode 100644 index 00000000..d08599ef --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt @@ -0,0 +1,25 @@ +package com.weeth.domain.file.domain.repository + +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus + +interface FileReader { + fun findAll( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus? = FileStatus.UPLOADED, + ): List + + fun findAll( + ownerType: FileOwnerType, + ownerIds: List, + status: FileStatus? = FileStatus.UPLOADED, + ): List + + fun exists( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus? = FileStatus.UPLOADED, + ): Boolean +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt new file mode 100644 index 00000000..e7b1cadf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt @@ -0,0 +1,71 @@ +package com.weeth.domain.file.domain.repository + +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import org.springframework.data.jpa.repository.JpaRepository + +interface FileRepository : + JpaRepository, + FileReader { + fun findAllByOwnerTypeAndOwnerId( + ownerType: FileOwnerType, + ownerId: Long, + ): List + + fun findAllByOwnerTypeAndOwnerIdAndStatus( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus, + ): List + + fun findAllByOwnerTypeAndOwnerIdIn( + ownerType: FileOwnerType, + ownerIds: List, + ): List + + fun findAllByOwnerTypeAndOwnerIdInAndStatus( + ownerType: FileOwnerType, + ownerIds: List, + status: FileStatus, + ): List + + fun existsByOwnerTypeAndOwnerId( + ownerType: FileOwnerType, + ownerId: Long, + ): Boolean + + fun existsByOwnerTypeAndOwnerIdAndStatus( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus, + ): Boolean + + override fun findAll( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus?, + ): List = + status?.let { findAllByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, it) } + ?: findAllByOwnerTypeAndOwnerId(ownerType, ownerId) + + override fun findAll( + ownerType: FileOwnerType, + ownerIds: List, + status: FileStatus?, + ): List { + if (ownerIds.isEmpty()) { + return emptyList() + } + return status?.let { findAllByOwnerTypeAndOwnerIdInAndStatus(ownerType, ownerIds, it) } + ?: findAllByOwnerTypeAndOwnerIdIn(ownerType, ownerIds) + } + + override fun exists( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus?, + ): Boolean = + status?.let { existsByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, it) } + ?: existsByOwnerTypeAndOwnerId(ownerType, ownerId) +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/vo/FileContentType.kt b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileContentType.kt new file mode 100644 index 00000000..e002ec43 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileContentType.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.file.domain.vo + +import com.weeth.domain.file.application.exception.UnsupportedFileContentTypeException + +@JvmInline +value class FileContentType( + val value: String, +) { + val normalized: String + get() = value.lowercase() + + val fileType: FileType + get() = FileType.fromContentType(normalized) ?: throw UnsupportedFileContentTypeException() + + init { + if (FileType.fromContentType(normalized) == null) { + throw UnsupportedFileContentTypeException() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/vo/FileExtension.kt b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileExtension.kt new file mode 100644 index 00000000..67a560d6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileExtension.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.file.domain.vo + +import com.weeth.domain.file.application.exception.UnsupportedFileExtensionException + +class FileExtension( + value: String, +) { + val normalized: String = value.lowercase() + val fileType: FileType + + init { + val resolvedType = FileType.fromExtension(normalized) ?: throw UnsupportedFileExtensionException() + fileType = resolvedType + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/vo/FileName.kt b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileName.kt new file mode 100644 index 00000000..8290fed3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileName.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.file.domain.vo + +class FileName( + value: String, +) { + val sanitized: String + val extension: FileExtension + + init { + val normalized = value.trim() + require(normalized.isNotBlank()) { "fileName은 비어 있을 수 없습니다." } + + val ext = normalized.substringAfterLast('.', "") + require(ext.isNotBlank()) { "fileName에는 확장자가 포함되어야 합니다." } + + extension = FileExtension(ext) + sanitized = normalized.replace(Regex("""[\\/:*?"<>|]"""), "_") + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/vo/FileType.kt b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileType.kt new file mode 100644 index 00000000..e2e2f025 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileType.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.file.domain.vo + +enum class FileType( + val contentType: String, + val extensions: Set, +) { + JPEG("image/jpeg", setOf("jpg", "jpeg")), + PNG("image/png", setOf("png")), + WEBP("image/webp", setOf("webp")), + PDF("application/pdf", setOf("pdf")), + ; + + companion object { + private val BY_CONTENT_TYPE = entries.associateBy { it.contentType } + private val BY_EXTENSION = entries.flatMap { type -> type.extensions.map { ext -> ext to type } }.toMap() + + /** + * API 요청의 contentType 검증 시 사용 + * image/png -> FileType.PNG 반환 + * */ + fun fromContentType(contentType: String): FileType? = BY_CONTENT_TYPE[contentType.trim().lowercase()] + + /** + * 파일명 확장자 검증 시 사용 + * png -> FileType.PNG 반환 + * */ + fun fromExtension(extension: String): FileType? = BY_EXTENSION[extension.trim().lowercase()] + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/vo/StorageKey.kt b/src/main/kotlin/com/weeth/domain/file/domain/vo/StorageKey.kt new file mode 100644 index 00000000..26bd7e4c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/vo/StorageKey.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.file.domain.vo + +import com.weeth.domain.file.domain.enums.FileOwnerType + +@JvmInline +value class StorageKey( + val value: String, +) { + init { + require(value.isNotBlank()) { "storageKey는 비어 있을 수 없습니다." } + require(STORAGE_KEY_PATTERN.matches(value)) { + "storageKey 형식이 올바르지 않습니다. 형식: {OWNER_TYPE}/{yyyy-MM}/{uuid}_{fileName}" + } + } + + companion object { + private val OWNER_TYPE_PATTERN = FileOwnerType.entries.joinToString("|") { it.name } + private val STORAGE_KEY_PATTERN = + Regex( + pattern = "^($OWNER_TYPE_PATTERN)/(\\d{4}-(0[1-9]|1[0-2]))/([0-9a-fA-F-]{36})_.+$", + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/infrastructure/CdnFileAccessUrlAdapter.kt b/src/main/kotlin/com/weeth/domain/file/infrastructure/CdnFileAccessUrlAdapter.kt new file mode 100644 index 00000000..d9fcddb0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/infrastructure/CdnFileAccessUrlAdapter.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.file.infrastructure + +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component + +@Component +@ConditionalOnProperty( + prefix = "app.file", + name = ["url-provider"], + havingValue = "CDN", // CDN으로 설정된 경우 이 어댑터를 사용합니다. +) +class CdnFileAccessUrlAdapter( + @Value("\${app.file.cdn-base-url:}") private val cdnBaseUrl: String, +) : FileAccessUrlPort { + /** storageKey를 CDN 조회 URL로 변환합니다. */ + override fun resolve(storageKey: String): String { + val normalizedBaseUrl = cdnBaseUrl.trimEnd('/') + return if (normalizedBaseUrl.isBlank()) { + storageKey + } else { + "$normalizedBaseUrl/$storageKey" + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileAccessUrlAdapter.kt b/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileAccessUrlAdapter.kt new file mode 100644 index 00000000..b11dd43d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileAccessUrlAdapter.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.file.infrastructure + +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.global.config.properties.AwsS3Properties +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component + +@Component +@ConditionalOnProperty( + prefix = "app.file", + name = ["url-provider"], + havingValue = "S3", // S3로 설정된 경우 이 어댑터를 사용합니다. + matchIfMissing = true, +) +class S3FileAccessUrlAdapter( + private val awsS3Properties: AwsS3Properties, +) : FileAccessUrlPort { + /** storageKey를 S3 공개 조회 URL로 변환합니다. */ + override fun resolve(storageKey: String): String { + val bucket = awsS3Properties.s3.bucket + val region = awsS3Properties.region.static + return "https://$bucket.s3.$region.amazonaws.com/$storageKey" + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapter.kt b/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapter.kt new file mode 100644 index 00000000..65208b2e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapter.kt @@ -0,0 +1,66 @@ +package com.weeth.domain.file.infrastructure + +import com.weeth.domain.file.application.exception.PresignedUrlGenerationException +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.port.FileUploadUrl +import com.weeth.domain.file.domain.port.FileUploadUrlPort +import com.weeth.domain.file.domain.vo.FileName +import com.weeth.global.common.exception.BaseException +import com.weeth.global.config.properties.AwsS3Properties +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import java.time.Duration +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.UUID + +/** S3 기반 업로드용 presigned URL 생성 어댑터입니다. */ +@Component +class S3FileUploadUrlAdapter( + private val s3Presigner: S3Presigner, + private val awsS3Properties: AwsS3Properties, + @param:Value("\${app.file.presigned-url-expiration-minutes:5}") + private val presignedUrlExpirationMinutes: Long, +) : FileUploadUrlPort { + override fun generateUploadUrl( + ownerType: FileOwnerType, + fileName: String, + ): FileUploadUrl = + runCatching { + val validatedFileName = FileName(fileName) + val storageKey = generateStorageKey(ownerType, validatedFileName.sanitized) + val putObjectRequest = + PutObjectRequest + .builder() + .bucket(awsS3Properties.s3.bucket) + .key(storageKey) + .build() + + val request = + PutObjectPresignRequest + .builder() + .signatureDuration(Duration.ofMinutes(presignedUrlExpirationMinutes)) + .putObjectRequest(putObjectRequest) + .build() + + val presigned = s3Presigner.presignPutObject(request) + FileUploadUrl(fileName = fileName, storageKey = storageKey, url = presigned.url().toString()) + }.getOrElse { e -> + if (e is BaseException) { + throw e + } + throw PresignedUrlGenerationException(cause = e) + } + + private fun generateStorageKey( + ownerType: FileOwnerType, + sanitizedFileName: String, + ): String { + val month = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")) + val uuid = UUID.randomUUID().toString() + return "${ownerType.name}/$month/${uuid}_$sanitizedFileName" + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/presentation/FileController.kt b/src/main/kotlin/com/weeth/domain/file/presentation/FileController.kt new file mode 100644 index 00000000..e8f4ec71 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/presentation/FileController.kt @@ -0,0 +1,39 @@ +package com.weeth.domain.file.presentation + +import com.weeth.domain.file.application.dto.response.UrlResponse +import com.weeth.domain.file.application.exception.FileErrorCode +import com.weeth.domain.file.application.usecase.command.GenerateFileUrlUsecase +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotEmpty +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "FILE") +@Validated +@RestController +@RequestMapping("/api/v4/files") +@ApiErrorCodeExample(FileErrorCode::class) +class FileController( + private val generateFileUrlUsecase: GenerateFileUrlUsecase, +) { + @GetMapping + @Operation(summary = "파일 업로드를 위한 presigned url을 요청하는 API 입니다.") + fun getUrl( + @Parameter(description = "파일 소유 타입", example = "POST") + @RequestParam ownerType: FileOwnerType, + @RequestParam @NotEmpty fileNames: List<@NotBlank String>, + ): CommonResponse> = + CommonResponse.success( + FileResponseCode.PRESIGNED_URL_GET_SUCCESS, + generateFileUrlUsecase.generateFileUploadUrls(ownerType, fileNames), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/file/presentation/FileResponseCode.kt b/src/main/kotlin/com/weeth/domain/file/presentation/FileResponseCode.kt new file mode 100644 index 00000000..a73a221c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/presentation/FileResponseCode.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.file.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class FileResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + PRESIGNED_URL_GET_SUCCESS(10600, HttpStatus.OK, "Presigned Url 반환에 성공했습니다"), +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/SavePenaltyRequest.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/SavePenaltyRequest.kt new file mode 100644 index 00000000..192d78d4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/SavePenaltyRequest.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.penalty.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class SavePenaltyRequest( + @field:Schema(description = "패널티 대상 사용자 ID", example = "1") + val userId: Long, + @field:Schema(description = "패널티 사유", example = "정기모임 무단 불참") + val penaltyDescription: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/UpdatePenaltyRequest.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/UpdatePenaltyRequest.kt new file mode 100644 index 00000000..443c8d1e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/UpdatePenaltyRequest.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.penalty.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class UpdatePenaltyRequest( + @field:Schema(description = "수정할 패널티 ID", example = "1") + val penaltyId: Long, + @field:Schema(description = "수정할 패널티 사유", example = "정기모임 무단 불참 (수정)") + val penaltyDescription: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyByCardinalResponse.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyByCardinalResponse.kt new file mode 100644 index 00000000..ec2353b6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyByCardinalResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.penalty.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PenaltyByCardinalResponse( + @field:Schema(description = "기수 번호", example = "4") + val cardinal: Int?, + @field:Schema(description = "해당 기수의 유저별 패널티 목록") + val responses: List, +) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyDetailResponse.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyDetailResponse.kt new file mode 100644 index 00000000..e0123541 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyDetailResponse.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.penalty.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class PenaltyDetailResponse( + @field:Schema(description = "패널티 ID", example = "1") + val penaltyId: Long, + @field:Schema(description = "기수 번호", example = "4") + val cardinal: Int?, + @field:Schema(description = "패널티 사유", example = "정기모임 무단 불참") + val penaltyDescription: String, + @field:Schema(description = "최종 수정 시간", example = "2026-02-19T01:00:00") + val time: LocalDateTime, +) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyResponse.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyResponse.kt new file mode 100644 index 00000000..a070bd2b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyResponse.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.penalty.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PenaltyResponse( + @field:Schema(description = "사용자 ID", example = "1") + val userId: Long, + @field:Schema(description = "사용자 이름", example = "홍길동") + val name: String, + @field:Schema(description = "패널티 횟수", example = "2") + val penaltyCount: Int, + @field:Schema(description = "소속 기수 목록", example = "[3, 4]") + val cardinals: List, + @field:Schema(description = "패널티 상세 목록") + val penalties: List, +) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.kt b/src/main/kotlin/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.kt new file mode 100644 index 00000000..23cef4fe --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.penalty.application.exception + +import com.weeth.global.common.exception.BaseException + +class AutoPenaltyDeleteNotAllowedException : BaseException(PenaltyErrorCode.AUTO_PENALTY_DELETE_NOT_ALLOWED) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt new file mode 100644 index 00000000..a1c0c6f5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.penalty.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class PenaltyErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("요청한 패널티 ID가 존재하지 않을 때 발생합니다.") + PENALTY_NOT_FOUND(20700, HttpStatus.NOT_FOUND, "존재하지 않는 패널티입니다."), + + @ExplainError("시스템에 의해 자동 부여된 패널티를 수동으로 삭제하려 할 때 발생합니다.") + AUTO_PENALTY_DELETE_NOT_ALLOWED(20701, HttpStatus.BAD_REQUEST, "자동 생성된 패널티는 삭제할 수 없습니다"), +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.kt b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.kt new file mode 100644 index 00000000..820c80df --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.penalty.application.exception + +import com.weeth.global.common.exception.BaseException + +class PenaltyNotFoundException : BaseException(PenaltyErrorCode.PENALTY_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt b/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt new file mode 100644 index 00000000..2f0420b5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt @@ -0,0 +1,55 @@ +package com.weeth.domain.penalty.application.mapper + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.penalty.application.dto.request.SavePenaltyRequest +import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalResponse +import com.weeth.domain.penalty.application.dto.response.PenaltyDetailResponse +import com.weeth.domain.penalty.application.dto.response.PenaltyResponse +import com.weeth.domain.penalty.domain.entity.Penalty +import org.springframework.stereotype.Component + +@Component +class PenaltyMapper { + fun toEntity( + request: SavePenaltyRequest, + clubMember: ClubMember, + cardinal: Cardinal, + ): Penalty = + Penalty( + clubMember = clubMember, + cardinal = cardinal, + penaltyDescription = request.penaltyDescription ?: "", + ) + + fun toResponse( + clubMember: ClubMember, + penalties: List, + clubMemberCardinals: List, + ): PenaltyResponse = + PenaltyResponse( + userId = clubMember.user.id, + name = clubMember.user.name, + penaltyCount = clubMember.penaltyCount, + cardinals = clubMemberCardinals.map { it.cardinal.cardinalNumber }, + penalties = penalties.map(::toDetailResponse), + ) + + fun toDetailResponse(penalty: Penalty): PenaltyDetailResponse = + PenaltyDetailResponse( + penaltyId = penalty.id, + cardinal = penalty.cardinal.cardinalNumber, + penaltyDescription = penalty.penaltyDescription, + time = penalty.createdAt, + ) + + fun toByCardinalResponse( + cardinal: Int?, + responses: List, + ): PenaltyByCardinalResponse = + PenaltyByCardinalResponse( + cardinal = cardinal, + responses = responses, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt new file mode 100644 index 00000000..0f44ff9c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt @@ -0,0 +1,37 @@ +package com.weeth.domain.penalty.application.usecase.command + +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.penalty.application.exception.AutoPenaltyDeleteNotAllowedException +import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException +import com.weeth.domain.penalty.domain.repository.PenaltyRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class DeletePenaltyUseCase( + private val penaltyRepository: PenaltyRepository, + private val clubMemberRepository: ClubMemberRepository, + private val clubPermissionPolicy: ClubPermissionPolicy, +) { + @Transactional + fun delete( + clubId: Long, + userId: Long, + penaltyId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val penalty = + penaltyRepository.findByIdWithLock(penaltyId) + ?: throw PenaltyNotFoundException() + if (penalty.clubMember.club.id != clubId) throw PenaltyNotFoundException() + + val lockedMember = + clubMemberRepository.findByIdWithLock(penalty.clubMember.id) + ?: throw PenaltyNotFoundException() + lockedMember.decrementPenaltyCount() + + penaltyRepository.delete(penalty) + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt new file mode 100644 index 00000000..eadc558f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt @@ -0,0 +1,41 @@ +package com.weeth.domain.penalty.application.usecase.command + +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.penalty.application.dto.request.SavePenaltyRequest +import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException +import com.weeth.domain.penalty.application.mapper.PenaltyMapper +import com.weeth.domain.penalty.domain.repository.PenaltyRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SavePenaltyUseCase( + private val penaltyRepository: PenaltyRepository, + private val clubMemberRepository: ClubMemberRepository, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, + private val mapper: PenaltyMapper, +) { + @Transactional + fun save( + clubId: Long, + userId: Long, + request: SavePenaltyRequest, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val clubMember = clubMemberPolicy.getActiveMember(clubId, request.userId) + val cardinal = clubMemberCardinalPolicy.getCurrentCardinal(clubMember) + + val penalty = mapper.toEntity(request, clubMember, cardinal) + penaltyRepository.save(penalty) + + val lockedMember = + clubMemberRepository.findByIdWithLock(clubMember.id) + ?: throw PenaltyNotFoundException() + lockedMember.incrementPenaltyCount() + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt new file mode 100644 index 00000000..8f958d69 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt @@ -0,0 +1,33 @@ +package com.weeth.domain.penalty.application.usecase.command + +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.penalty.application.dto.request.UpdatePenaltyRequest +import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException +import com.weeth.domain.penalty.domain.repository.PenaltyRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UpdatePenaltyUseCase( + private val penaltyRepository: PenaltyRepository, + private val clubPermissionPolicy: ClubPermissionPolicy, +) { + @Transactional + fun update( + clubId: Long, + userId: Long, + request: UpdatePenaltyRequest, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val penalty = + penaltyRepository.findByIdOrNull(request.penaltyId) + ?: throw PenaltyNotFoundException() + if (penalty.clubMember.club.id != clubId) throw PenaltyNotFoundException() + + if (!request.penaltyDescription.isNullOrBlank()) { + penalty.update(request.penaltyDescription) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt new file mode 100644 index 00000000..06eb2b52 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt @@ -0,0 +1,77 @@ +package com.weeth.domain.penalty.application.usecase.query + +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalResponse +import com.weeth.domain.penalty.application.dto.response.PenaltyResponse +import com.weeth.domain.penalty.application.mapper.PenaltyMapper +import com.weeth.domain.penalty.domain.repository.PenaltyRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetPenaltyQueryService( + private val penaltyRepository: PenaltyRepository, + private val clubMemberCardinalReader: ClubMemberCardinalReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, + private val cardinalReader: CardinalReader, + private val mapper: PenaltyMapper, +) { + fun findAllByCardinal( + clubId: Long, + userId: Long, + cardinalNumber: Int?, + ): List { + clubPermissionPolicy.requireAdmin(clubId, userId) + val cardinals = + if (cardinalNumber == null) { + cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId) + } else { + listOf(cardinalReader.findByClubIdAndCardinalNumber(clubId, cardinalNumber) ?: return emptyList()) + } + + return cardinals.map { cardinal -> + val penalties = penaltyRepository.findByClubIdAndCardinalIdOrderByIdDesc(clubId, cardinal.id) + val clubMembers = penalties.map { it.clubMember }.distinct() + val memberCardinalsMap = + clubMemberCardinalReader + .findAllByClubMembers( + clubMembers, + ).groupBy { it.clubMember.id } + + val responses = + penalties + .groupBy { it.clubMember.id } + .entries + .map { (clubMemberId, memberPenalties) -> + val clubMember = memberPenalties.first().clubMember + val memberCardinals = memberCardinalsMap[clubMemberId] ?: emptyList() + mapper.toResponse(clubMember, memberPenalties, memberCardinals) + }.sortedBy { it.userId } + + mapper.toByCardinalResponse(cardinal.cardinalNumber, responses) + } + } + + fun findByUser( + clubId: Long, + userId: Long, + ): PenaltyResponse { + val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) + val currentCardinal = clubMemberCardinalPolicy.getCurrentCardinal(clubMember) + val penalties = + penaltyRepository.findByClubMemberIdAndCardinalIdOrderByIdDesc( + clubMember.id, + currentCardinal.id, + ) + val clubMemberCardinals = clubMemberCardinalReader.findAllByClubMember(clubMember) + + return mapper.toResponse(clubMember, penalties, clubMemberCardinals) + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt new file mode 100644 index 00000000..8159ede2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt @@ -0,0 +1,50 @@ +package com.weeth.domain.penalty.domain.entity + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.penalty.domain.enums.PenaltyType +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne + +@Entity +class Penalty( + clubMember: ClubMember, + cardinal: Cardinal, + penaltyDescription: String, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "penalty_id") + var id: Long = 0L + private set + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_member_id") + var clubMember: ClubMember = clubMember + private set + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cardinal_id") + var cardinal: Cardinal = cardinal + private set + + @Enumerated(EnumType.STRING) + var penaltyType: PenaltyType = PenaltyType.PENALTY + private set + + var penaltyDescription: String = penaltyDescription + private set + + fun update(penaltyDescription: String) { + this.penaltyDescription = penaltyDescription + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/enums/PenaltyType.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/enums/PenaltyType.kt new file mode 100644 index 00000000..359fd771 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/enums/PenaltyType.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.penalty.domain.enums + +enum class PenaltyType { + PENALTY, +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyReader.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyReader.kt new file mode 100644 index 00000000..8f5c502d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyReader.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.penalty.domain.repository + +interface PenaltyReader { + fun countByClubMemberIdAndCardinalId( + clubMemberId: Long, + cardinalId: Long, + ): Int +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt new file mode 100644 index 00000000..049fffd2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt @@ -0,0 +1,43 @@ +package com.weeth.domain.penalty.domain.repository + +import com.weeth.domain.penalty.domain.entity.Penalty +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +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.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param + +interface PenaltyRepository : + JpaRepository, + PenaltyReader { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT p FROM Penalty p WHERE p.id = :penaltyId") + fun findByIdWithLock( + @Param("penaltyId") penaltyId: Long, + ): Penalty? + + @Query("SELECT COUNT(p) FROM Penalty p WHERE p.clubMember.id = :clubMemberId AND p.cardinal.id = :cardinalId") + override fun countByClubMemberIdAndCardinalId( + @Param("clubMemberId") clubMemberId: Long, + @Param("cardinalId") cardinalId: Long, + ): Int + + @Query( + "SELECT p FROM Penalty p JOIN FETCH p.clubMember cm JOIN FETCH cm.user JOIN FETCH p.cardinal WHERE cm.id = :clubMemberId AND p.cardinal.id = :cardinalId ORDER BY p.id DESC", + ) + fun findByClubMemberIdAndCardinalIdOrderByIdDesc( + clubMemberId: Long, + cardinalId: Long, + ): List + + @Query( + "SELECT p FROM Penalty p JOIN FETCH p.clubMember cm JOIN FETCH cm.user JOIN FETCH p.cardinal WHERE cm.club.id = :clubId AND p.cardinal.id = :cardinalId ORDER BY p.id DESC", + ) + fun findByClubIdAndCardinalIdOrderByIdDesc( + clubId: Long, + cardinalId: Long, + ): List +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt new file mode 100644 index 00000000..fc6d75d6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt @@ -0,0 +1,87 @@ +package com.weeth.domain.penalty.presentation + +import com.weeth.domain.penalty.application.dto.request.SavePenaltyRequest +import com.weeth.domain.penalty.application.dto.request.UpdatePenaltyRequest +import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalResponse +import com.weeth.domain.penalty.application.exception.PenaltyErrorCode +import com.weeth.domain.penalty.application.usecase.command.DeletePenaltyUseCase +import com.weeth.domain.penalty.application.usecase.command.SavePenaltyUseCase +import com.weeth.domain.penalty.application.usecase.command.UpdatePenaltyUseCase +import com.weeth.domain.penalty.application.usecase.query.GetPenaltyQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +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 + +@Tag(name = "PENALTY ADMIN", description = "[ADMIN] 패널티 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/clubs/{clubId}/penalties") +@ApiErrorCodeExample(PenaltyErrorCode::class) +class PenaltyAdminController( + private val savePenaltyUseCase: SavePenaltyUseCase, + private val updatePenaltyUseCase: UpdatePenaltyUseCase, + private val deletePenaltyUseCase: DeletePenaltyUseCase, + private val getPenaltyQueryService: GetPenaltyQueryService, +) { + @PostMapping + @Operation(summary = "패널티 부여", hidden = true) + fun assignPenalty( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @Valid @RequestBody request: SavePenaltyRequest, + ): CommonResponse { + savePenaltyUseCase.save(clubId, userId, request) + return CommonResponse.success(PenaltyResponseCode.PENALTY_ASSIGN_SUCCESS) + } + + @PatchMapping + @Operation(summary = "패널티 수정", hidden = true) + fun update( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @Valid @RequestBody request: UpdatePenaltyRequest, + ): CommonResponse { + updatePenaltyUseCase.update(clubId, userId, request) + return CommonResponse.success(PenaltyResponseCode.PENALTY_UPDATE_SUCCESS) + } + + @GetMapping + @Operation(summary = "전체 패널티 조회", hidden = true) + fun findAll( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @RequestParam(required = false) cardinal: Int?, + ): CommonResponse> = + CommonResponse.success( + PenaltyResponseCode.PENALTY_FIND_ALL_SUCCESS, + getPenaltyQueryService.findAllByCardinal(clubId, userId, cardinal), + ) + + @DeleteMapping + @Operation(summary = "패널티 삭제", hidden = true) + fun delete( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @RequestParam penaltyId: Long, + ): CommonResponse { + deletePenaltyUseCase.delete(clubId, userId, penaltyId) + return CommonResponse.success(PenaltyResponseCode.PENALTY_DELETE_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyResponseCode.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyResponseCode.kt new file mode 100644 index 00000000..20226bb2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyResponseCode.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.penalty.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class PenaltyResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + PENALTY_ASSIGN_SUCCESS(10700, HttpStatus.OK, "페널티가 성공적으로 부여되었습니다."), + PENALTY_FIND_ALL_SUCCESS(10701, HttpStatus.OK, "모든 패널티가 성공적으로 조회되었습니다."), + PENALTY_DELETE_SUCCESS(10702, HttpStatus.OK, "패널티가 성공적으로 삭제되었습니다."), + PENALTY_UPDATE_SUCCESS(10703, HttpStatus.OK, "패널티를 성공적으로 수정했습니다."), + PENALTY_USER_FIND_SUCCESS(10704, HttpStatus.OK, "패널티가 성공적으로 조회되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt new file mode 100644 index 00000000..55898566 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt @@ -0,0 +1,36 @@ +package com.weeth.domain.penalty.presentation + +import com.weeth.domain.penalty.application.dto.response.PenaltyResponse +import com.weeth.domain.penalty.application.exception.PenaltyErrorCode +import com.weeth.domain.penalty.application.usecase.query.GetPenaltyQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "PENALTY", description = "패널티 API") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/penalties") +@ApiErrorCodeExample(PenaltyErrorCode::class) +class PenaltyUserController( + private val getPenaltyQueryService: GetPenaltyQueryService, +) { + @GetMapping + @Operation(summary = "본인 패널티 조회", hidden = true) + fun findAllPenalties( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + PenaltyResponseCode.PENALTY_USER_FIND_SUCCESS, + getPenaltyQueryService.findByUser(clubId, userId), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleSaveRequest.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleSaveRequest.kt new file mode 100644 index 00000000..d32c6b91 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleSaveRequest.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.schedule.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.springframework.format.annotation.DateTimeFormat +import java.time.LocalDateTime + +data class ScheduleSaveRequest( + @field:Schema(description = "일정 제목", example = "MT") + @field:NotBlank + val title: String, + @field:Schema(description = "일정 내용", example = "1박 2일 MT입니다.") + @field:Size(max = 500) + val content: String? = null, + @field:Schema(description = "장소", example = "가평") + val location: String? = null, + @field:Schema(description = "기수", example = "4") + val cardinal: Int, + @field:Schema(description = "시작 시간", example = "2026-03-25T10:00:00") + @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + val start: LocalDateTime, + @field:Schema(description = "종료 시간", example = "2026-03-25T12:00:00") + @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + val end: LocalDateTime, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleUpdateRequest.kt new file mode 100644 index 00000000..93c6799b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleUpdateRequest.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.schedule.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size +import java.time.LocalDateTime + +data class ScheduleUpdateRequest( + @field:Schema(description = "일정 제목 (null=변경 안 함)", example = "MT") + val title: String?, + @field:Schema(description = "일정 내용 (null=변경 안 함)", example = "1박 2일 MT입니다.") + @field:Size(max = 500) + val content: String?, + @field:Schema(description = "장소 (null=변경 안 함)", example = "가평") + val location: String?, + @field:Schema(description = "시작 시간 (null=변경 안 함)", example = "2026-03-28T10:00:00") + val start: LocalDateTime?, + @field:Schema(description = "종료 시간 (null=변경 안 함)", example = "2026-03-28T10:00:00") + val end: LocalDateTime?, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt new file mode 100644 index 00000000..0ab58b3b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.schedule.application.dto.response + +import com.weeth.domain.schedule.domain.enums.Type +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class EventResponse( + @field:Schema(description = "일정 ID", example = "1") + val id: Long, + @field:Schema(description = "일정 제목", example = "MT") + val title: String, + @field:Schema(description = "일정 내용") + val content: String?, + @field:Schema(description = "장소", example = "가평") + val location: String?, + @field:Schema(description = "작성자 이름", example = "이지훈") + val name: String?, + @field:Schema(description = "기수", example = "4") + val cardinal: Int, + @field:Schema(description = "일정 타입", example = "EVENT") + val type: Type, + @field:Schema(description = "시작 시간") + val start: LocalDateTime, + @field:Schema(description = "종료 시간") + val end: LocalDateTime, + @field:Schema(description = "생성 시간") + val createdAt: LocalDateTime?, + @field:Schema(description = "수정 시간") + val modifiedAt: LocalDateTime?, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleResponse.kt new file mode 100644 index 00000000..aa487473 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleResponse.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.schedule.application.dto.response + +import com.weeth.domain.schedule.domain.enums.Type +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class ScheduleResponse( + @field:Schema(description = "일정 ID", example = "1") + val id: Long, + @field:Schema(description = "제목", example = "1차 정기모임") + val title: String, + @field:Schema(description = "시작 시간") + val start: LocalDateTime, + @field:Schema(description = "종료 시간") + val end: LocalDateTime, + @field:Schema(description = "일정 유형", example = "SESSION") + val type: Type, + @field:Schema(description = "장소", example = "가천대 체육관") + val location: String?, + @field:Schema(description = "기수", example = "7") + val cardinal: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt new file mode 100644 index 00000000..0848ded0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.schedule.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class EventErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("요청한 일정 ID에 해당하는 일정이 존재하지 않거나 동아리에 속하지 않은 경우에 발생합니다.") + EVENT_NOT_FOUND(20800, HttpStatus.NOT_FOUND, "존재하지 않는 일정입니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventNotFoundException.kt b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventNotFoundException.kt new file mode 100644 index 00000000..56968357 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.schedule.application.exception + +import com.weeth.global.common.exception.BaseException + +class EventNotFoundException : BaseException(EventErrorCode.EVENT_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt new file mode 100644 index 00000000..95703137 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt @@ -0,0 +1,43 @@ +package com.weeth.domain.schedule.application.mapper + +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest +import com.weeth.domain.schedule.application.dto.response.EventResponse +import com.weeth.domain.schedule.domain.entity.Event +import com.weeth.domain.schedule.domain.enums.Type +import com.weeth.domain.user.domain.entity.User +import org.springframework.stereotype.Component + +@Component +class EventMapper { + fun toResponse(event: Event): EventResponse = + EventResponse( + id = event.id, + title = event.title, + content = event.content, + location = event.location, + name = event.user?.name, + cardinal = event.cardinal, + type = Type.EVENT, + start = event.start, + end = event.end, + createdAt = event.createdAt, + modifiedAt = event.modifiedAt, + ) + + fun toEntity( + club: Club, + request: ScheduleSaveRequest, + user: User, + ): Event = + Event.create( + club = club, + title = request.title, + content = request.content, + location = request.location, + cardinal = request.cardinal, + start = request.start, + end = request.end, + user = user, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt new file mode 100644 index 00000000..6d8ad1d4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt @@ -0,0 +1,32 @@ +package com.weeth.domain.schedule.application.mapper + +import com.weeth.domain.schedule.application.dto.response.ScheduleResponse +import com.weeth.domain.schedule.domain.entity.Event +import com.weeth.domain.schedule.domain.enums.Type +import com.weeth.domain.session.domain.entity.Session +import org.springframework.stereotype.Component + +@Component +class ScheduleMapper { + fun toResponse(event: Event): ScheduleResponse = + ScheduleResponse( + id = event.id, + title = event.title, + start = event.start, + end = event.end, + type = Type.EVENT, + location = event.location, + cardinal = event.cardinal, + ) + + fun toResponse(session: Session): ScheduleResponse = + ScheduleResponse( + id = session.id, + title = session.title, + start = session.start, + end = session.end, + type = Type.SESSION, + location = session.location, + cardinal = session.cardinal, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt new file mode 100644 index 00000000..8d669057 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt @@ -0,0 +1,72 @@ +package com.weeth.domain.schedule.application.usecase.command + +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest +import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest +import com.weeth.domain.schedule.application.exception.EventNotFoundException +import com.weeth.domain.schedule.application.mapper.EventMapper +import com.weeth.domain.schedule.domain.repository.EventRepository +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageEventUseCase( + private val eventRepository: EventRepository, + private val userReader: UserReader, + private val cardinalReader: CardinalReader, + private val eventMapper: EventMapper, + private val clubReader: ClubReader, + private val clubPermissionPolicy: ClubPermissionPolicy, +) { + @Transactional + fun create( + clubId: Long, + request: ScheduleSaveRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val club = clubReader.getClubById(clubId) + val user = userReader.getById(userId) + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) + ?: throw CardinalNotFoundException() + eventRepository.save(eventMapper.toEntity(club, request, user)) + } + + @Transactional + fun update( + clubId: Long, + eventId: Long, + request: ScheduleUpdateRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val user = userReader.getById(userId) + val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() + if (event.club.id != clubId) throw EventNotFoundException() + event.update( + title = request.title ?: event.title, + content = request.content ?: event.content, + location = request.location ?: event.location, + start = request.start ?: event.start, + end = request.end ?: event.end, + user = user, + ) + } + + @Transactional + fun delete( + clubId: Long, + eventId: Long, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() + if (event.club.id != clubId) throw EventNotFoundException() + eventRepository.delete(event) + } +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt new file mode 100644 index 00000000..2725bd43 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt @@ -0,0 +1,85 @@ +package com.weeth.domain.schedule.application.usecase.query + +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.schedule.application.dto.response.EventResponse +import com.weeth.domain.schedule.application.dto.response.ScheduleResponse +import com.weeth.domain.schedule.application.exception.EventNotFoundException +import com.weeth.domain.schedule.application.mapper.EventMapper +import com.weeth.domain.schedule.application.mapper.ScheduleMapper +import com.weeth.domain.schedule.domain.repository.EventRepository +import com.weeth.domain.session.domain.repository.SessionReader +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class GetScheduleQueryService( + private val eventRepository: EventRepository, + private val sessionReader: SessionReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val scheduleMapper: ScheduleMapper, + private val eventMapper: EventMapper, +) { + fun findEvent( + clubId: Long, + userId: Long, + eventId: Long, + ): EventResponse { + clubMemberPolicy.getActiveMember(clubId, userId) + val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() + + if (event.club.id != clubId) throw EventNotFoundException() + + return eventMapper.toResponse(event) + } + + fun findMonthly( + clubId: Long, + userId: Long, + start: LocalDateTime, + end: LocalDateTime, + ): List { + clubMemberPolicy.getActiveMember(clubId, userId) + + val events = + eventRepository + .findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, end, start) + .map { scheduleMapper.toResponse(it) } + + val sessions = + sessionReader + .findAllByClubIdAndStartBetween(clubId, start, end) + .map { scheduleMapper.toResponse(it) } + + return (events + sessions).sortedBy { it.start } + } + + fun findYearly( + clubId: Long, + userId: Long, + year: Int, + ): Map> { + clubMemberPolicy.getActiveMember(clubId, userId) + + val start = LocalDateTime.of(year, 1, 1, 0, 0) + val end = LocalDateTime.of(year, 12, 31, 23, 59, 59) + + val events = + eventRepository + .findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, end, start) + .map { scheduleMapper.toResponse(it) } + + val sessions = + sessionReader + .findAllByClubIdAndStartBetween(clubId, start, end) + .map { scheduleMapper.toResponse(it) } + + return (events + sessions) + .sortedBy { it.start } + .flatMap { schedule -> + (schedule.start.monthValue..schedule.end.monthValue).map { month -> month to schedule } + }.groupBy({ it.first }, { it.second }) + } +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt new file mode 100644 index 00000000..7d2c05f9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt @@ -0,0 +1,82 @@ +package com.weeth.domain.schedule.domain.entity + +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import java.time.LocalDateTime + +@Entity +class Event( + club: Club, + var title: String, + @Column(length = 500) + var content: String? = null, + var location: String? = null, + var cardinal: Int, + var start: LocalDateTime, + var end: LocalDateTime, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + var user: User? = null, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0 + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + var club: Club = club + private set + + fun update( + title: String, + content: String?, + location: String?, + start: LocalDateTime, + end: LocalDateTime, + user: User?, + ) { + require(title.isNotBlank()) { "제목은 필수입니다" } + require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } + this.title = title + this.content = content + this.location = location + this.start = start + this.end = end + this.user = user + } + + companion object { + fun create( + club: Club, + title: String, + content: String?, + location: String?, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + user: User?, + ): Event { + require(title.isNotBlank()) { "제목은 필수입니다" } + require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } + return Event( + club = club, + title = title, + content = content, + location = location, + cardinal = cardinal, + start = start, + end = end, + user = user, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/enums/Type.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/enums/Type.kt new file mode 100644 index 00000000..054b9422 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/enums/Type.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.schedule.domain.enums + +enum class Type { + EVENT, + SESSION, +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt new file mode 100644 index 00000000..e7fe161f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.schedule.domain.repository + +import com.weeth.domain.schedule.domain.entity.Event +import java.time.LocalDateTime + +interface EventReader { + fun findByDateRange( + start: LocalDateTime, + end: LocalDateTime, + ): List + + fun findByClubIdAndDateRange( + clubId: Long, + start: LocalDateTime, + end: LocalDateTime, + ): List + + fun findAllByCardinal(cardinal: Int): List +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt new file mode 100644 index 00000000..f9ba7014 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt @@ -0,0 +1,43 @@ +package com.weeth.domain.schedule.domain.repository + +import com.weeth.domain.schedule.domain.entity.Event +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 + +interface EventRepository : + JpaRepository, + EventReader { + fun findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( + end: LocalDateTime, + start: LocalDateTime, + ): List + + override fun findByDateRange( + start: LocalDateTime, + end: LocalDateTime, + ): List = findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start) + + override fun findByClubIdAndDateRange( + clubId: Long, + start: LocalDateTime, + end: LocalDateTime, + ): List = findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, end, start) + + override fun findAllByCardinal(cardinal: Int): List + + fun findAllByClubIdAndCardinal( + clubId: Long, + cardinal: Int, + ): List + + @Query( + "SELECT e FROM Event e WHERE e.club.id = :clubId AND e.start <= :end AND e.end >= :start ORDER BY e.start ASC", + ) + fun findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( + @Param("clubId") clubId: Long, + @Param("end") end: LocalDateTime, + @Param("start") start: LocalDateTime, + ): List +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt new file mode 100644 index 00000000..32e9def8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt @@ -0,0 +1,67 @@ +package com.weeth.domain.schedule.presentation + +import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest +import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest +import com.weeth.domain.schedule.application.exception.EventErrorCode +import com.weeth.domain.schedule.application.usecase.command.ManageEventUseCase +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "EVENT ADMIN", description = "[ADMIN] 일정 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/clubs/{clubId}/events") +@ApiErrorCodeExample(EventErrorCode::class) +class EventAdminController( + private val manageEventUseCase: ManageEventUseCase, +) { + @PostMapping + @Operation(summary = "일정 생성") + fun create( + @TsidParam + @TsidPathVariable clubId: Long, + @Valid @RequestBody dto: ScheduleSaveRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageEventUseCase.create(clubId, dto, userId) + return CommonResponse.success(ScheduleResponseCode.EVENT_SAVE_SUCCESS) + } + + @PatchMapping("/{eventId}") + @Operation(summary = "일정 수정") + fun update( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable eventId: Long, + @Valid @RequestBody dto: ScheduleUpdateRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageEventUseCase.update(clubId, eventId, dto, userId) + return CommonResponse.success(ScheduleResponseCode.EVENT_UPDATE_SUCCESS) + } + + @DeleteMapping("/{eventId}") + @Operation(summary = "일정 삭제") + fun delete( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable eventId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageEventUseCase.delete(clubId, eventId, userId) + return CommonResponse.success(ScheduleResponseCode.EVENT_DELETE_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt new file mode 100644 index 00000000..343e4ee7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt @@ -0,0 +1,38 @@ +package com.weeth.domain.schedule.presentation + +import com.weeth.domain.schedule.application.dto.response.EventResponse +import com.weeth.domain.schedule.application.exception.EventErrorCode +import com.weeth.domain.schedule.application.usecase.query.GetScheduleQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "EVENT", description = "일정 API") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/events") +@ApiErrorCodeExample(EventErrorCode::class) +class EventController( + private val getScheduleQueryService: GetScheduleQueryService, +) { + @GetMapping("/{eventId}") + @Operation(summary = "일정 상세 조회") + fun getEvent( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable eventId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + ScheduleResponseCode.EVENT_FIND_SUCCESS, + getScheduleQueryService.findEvent(clubId, userId, eventId), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt new file mode 100644 index 00000000..150939d4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt @@ -0,0 +1,51 @@ +package com.weeth.domain.schedule.presentation + +import com.weeth.domain.schedule.application.dto.response.ScheduleResponse +import com.weeth.domain.schedule.application.usecase.query.GetScheduleQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDateTime + +@Tag(name = "SCHEDULE", description = "캘린더 조회 API") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/schedules") +class ScheduleController( + private val getScheduleQueryService: GetScheduleQueryService, +) { + @GetMapping("/monthly") + @Operation(summary = "월별 일정 조회") + fun findByMonthly( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) start: LocalDateTime, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) end: LocalDateTime, + ): CommonResponse> = + CommonResponse.success( + ScheduleResponseCode.SCHEDULE_MONTHLY_FIND_SUCCESS, + getScheduleQueryService.findMonthly(clubId, userId, start, end), + ) + + @GetMapping("/yearly") + @Operation(summary = "연도별 일정 조회") + fun findByYearly( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @RequestParam year: Int, + ): CommonResponse>> = + CommonResponse.success( + ScheduleResponseCode.SCHEDULE_YEARLY_FIND_SUCCESS, + getScheduleQueryService.findYearly(clubId, userId, year), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt new file mode 100644 index 00000000..cb7d5c5f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.schedule.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class ScheduleResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + EVENT_SAVE_SUCCESS(10800, HttpStatus.OK, "일정이 성공적으로 생성되었습니다."), + EVENT_UPDATE_SUCCESS(10801, HttpStatus.OK, "일정이 성공적으로 수정되었습니다."), + EVENT_DELETE_SUCCESS(10802, HttpStatus.OK, "일정이 성공적으로 삭제되었습니다."), + EVENT_FIND_SUCCESS(10803, HttpStatus.OK, "일정이 성공적으로 조회되었습니다."), + SCHEDULE_MONTHLY_FIND_SUCCESS(10804, HttpStatus.OK, "월별 일정이 성공적으로 조회되었습니다."), + SCHEDULE_YEARLY_FIND_SUCCESS(10805, HttpStatus.OK, "연도별 일정이 성공적으로 조회되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionCreateRequest.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionCreateRequest.kt new file mode 100644 index 00000000..3acb49a1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionCreateRequest.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.session.application.dto.request + +import com.weeth.domain.session.domain.enums.RecurrenceType +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import java.time.LocalDate +import java.time.LocalDateTime + +data class SessionCreateRequest( + @field:Schema(description = "세션 제목", example = "1차 정기모임") + @field:NotBlank + val title: String, + @field:Schema(description = "세션 내용", example = "OT 및 자기소개") + @field:Size(max = 500) + val content: String?, + @field:Schema(description = "모임 장소", example = "공학관 401호") + val location: String?, + @field:Schema(description = "기수", example = "1") + val cardinal: Int, + @field:Schema(description = "시작 시간", example = "2026-03-26T10:00:00") + val start: LocalDateTime, + @field:Schema(description = "종료 시간", example = "2026-03-26T22:00:00") + val end: LocalDateTime, + @field:Schema(description = "반복 설정 (null=비반복, DAILY/WEEKLY/MONTHLY)") + val recurrenceType: RecurrenceType?, + @field:Schema(description = "반복 종료일 (반복 설정 시 필수, 시작일 기준 최대 1년)", example = "2026-06-30") + val recurrenceEndDate: LocalDate?, +) diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionUpdateRequest.kt new file mode 100644 index 00000000..9c14f0ee --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionUpdateRequest.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.session.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size +import java.time.LocalDateTime + +data class SessionUpdateRequest( + @field:Schema(description = "세션 제목 (null=변경 안 함)", example = "1차 정기모임") + val title: String?, + @field:Schema(description = "세션 내용 (null=변경 안 함)", example = "OT 및 자기소개") + @field:Size(max = 500) + val content: String?, + @field:Schema(description = "모임 장소 (null=변경 안 함)", example = "공학관 401호") + val location: String?, + @field:Schema(description = "시작 시간 (null=변경 안 함)", example = "2026-03-27T10:00:00") + val start: LocalDateTime?, + @field:Schema(description = "종료 시간 (null=변경 안 함)", example = "2026-03-27T22:00:00") + val end: LocalDateTime?, +) diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/response/ClosedSessionCountResponse.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/response/ClosedSessionCountResponse.kt new file mode 100644 index 00000000..40ea6c6b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/response/ClosedSessionCountResponse.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.session.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ClosedSessionCountResponse( + @field:Schema(description = "이미 진행된(CLOSED) 세션 수") + val closedSessionCount: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionGroupResponse.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionGroupResponse.kt new file mode 100644 index 00000000..cd51bafa --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionGroupResponse.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.session.application.dto.response + +import com.weeth.domain.session.domain.enums.RecurrenceType +import com.weeth.domain.session.domain.enums.SessionGroupStatus +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class SessionGroupResponse( + @field:Schema(description = "반복 그룹 ID (null이면 비반복)") + val groupId: Long?, + @field:Schema(description = "세션 제목") + val title: String, + @field:Schema(description = "반복 설정 (null이면 비반복)") + val recurrenceType: RecurrenceType?, + @field:Schema(description = "반복 설명 텍스트 (예: '매주 수요일 19시')") + val recurrenceDescription: String?, + @field:Schema(description = "그룹 첫 세션 시작일") + val startDate: LocalDate?, + @field:Schema(description = "반복 종료일") + val endDate: LocalDate?, + @field:Schema(description = "완료(CLOSED) 세션 수") + val completedCount: Int, + @field:Schema(description = "전체 세션 수") + val totalCount: Int, + @field:Schema(description = "그룹 상태") + val status: SessionGroupStatus, + @field:Schema(description = "세션 목록") + val sessions: List, +) diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfoResponse.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfoResponse.kt new file mode 100644 index 00000000..6836a50b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfoResponse.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.session.application.dto.response + +import com.weeth.domain.session.domain.enums.SessionStatus +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class SessionInfoResponse( + @field:Schema(description = "정기모임 ID", example = "1") + val id: Long, + @field:Schema(description = "기수", example = "4") + val cardinal: Int, + @field:Schema(description = "제목", example = "1차 정기모임") + val title: String, + @field:Schema(description = "시작 시간") + val start: LocalDateTime, + @field:Schema(description = "종료 시간") + val end: LocalDateTime, + @field:Schema(description = "상태") + val status: SessionStatus, +) diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfosResponse.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfosResponse.kt new file mode 100644 index 00000000..fee6c3f7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfosResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.session.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class SessionInfosResponse( + @field:Schema(description = "이번 주 정기모임 목록") + val thisWeek: List, + @field:Schema(description = "정기모임 목록") + val sessions: List, +) diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionResponse.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionResponse.kt new file mode 100644 index 00000000..6f28df2d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionResponse.kt @@ -0,0 +1,34 @@ +package com.weeth.domain.session.application.dto.response + +import com.fasterxml.jackson.annotation.JsonInclude +import com.weeth.domain.schedule.domain.enums.Type +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class SessionResponse( + @field:Schema(description = "정기모임 ID", example = "1") + val id: Long, + @field:Schema(description = "제목", example = "1차 정기모임") + val title: String, + @field:Schema(description = "내용") + val content: String?, + @field:Schema(description = "장소", example = "공학관 401호") + val location: String?, + @field:Schema(description = "작성자 이름", example = "이지훈") + val name: String?, + @field:Schema(description = "기수", example = "4") + val cardinal: Int, + @field:Schema(description = "일정 타입", example = "MEETING") + val type: Type, + @field:Schema(description = "출석 코드", example = "1234") + val code: Int?, + @field:Schema(description = "시작 시간") + val start: LocalDateTime, + @field:Schema(description = "종료 시간") + val end: LocalDateTime, + @field:Schema(description = "생성 시간") + val createdAt: LocalDateTime?, + @field:Schema(description = "수정 시간") + val modifiedAt: LocalDateTime?, +) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/ClosedSessionIncludedException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/ClosedSessionIncludedException.kt new file mode 100644 index 00000000..d6392370 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/ClosedSessionIncludedException.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.domain.session.application.dto.response.ClosedSessionCountResponse +import com.weeth.global.common.exception.BaseException + +class ClosedSessionIncludedException( + errorCode: SessionErrorCode, + closedSessionCount: Int, +) : BaseException( + errorCode = errorCode, + data = ClosedSessionCountResponse(closedSessionCount), + ) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/EndBeforeStartException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/EndBeforeStartException.kt new file mode 100644 index 00000000..a414626c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/EndBeforeStartException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class EndBeforeStartException : BaseException(SessionErrorCode.END_BEFORE_START) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateBeforeStartException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateBeforeStartException.kt new file mode 100644 index 00000000..65415dcf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateBeforeStartException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class RecurrenceEndDateBeforeStartException : BaseException(SessionErrorCode.RECURRENCE_END_DATE_BEFORE_START) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateExceedsMaxException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateExceedsMaxException.kt new file mode 100644 index 00000000..e2a418e4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateExceedsMaxException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class RecurrenceEndDateExceedsMaxException : BaseException(SessionErrorCode.RECURRENCE_END_DATE_EXCEEDS_MAX) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateRequiredException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateRequiredException.kt new file mode 100644 index 00000000..f71d7ac9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateRequiredException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class RecurrenceEndDateRequiredException : BaseException(SessionErrorCode.RECURRENCE_END_DATE_REQUIRED) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt new file mode 100644 index 00000000..4b04a050 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt @@ -0,0 +1,46 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class SessionErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("요청한 정기모임 ID에 해당하는 정기모임이 존재하지 않을 때 발생합니다.") + SESSION_NOT_FOUND(20300, HttpStatus.NOT_FOUND, "존재하지 않는 정기모임입니다."), + + @ExplainError("출석 요청 시각이 정기모임 시작 10분 전 ~ 종료 10분 후 범위를 벗어날 때 발생합니다.") + SESSION_NOT_IN_PROGRESS(20301, HttpStatus.BAD_REQUEST, "출석 가능한 시간이 아닙니다."), + + @ExplainError("반복 설정 시 반복 종료일이 필수인데 제공되지 않았을 때 발생합니다.") + RECURRENCE_END_DATE_REQUIRED(20302, HttpStatus.BAD_REQUEST, "반복 종료일은 필수입니다."), + + @ExplainError("반복 종료일이 세션 시작일 이전이거나 같을 때 발생합니다.") + RECURRENCE_END_DATE_BEFORE_START(20303, HttpStatus.BAD_REQUEST, "반복 종료일은 시작일 이후여야 합니다."), + + @ExplainError("요청한 세션 그룹 ID에 해당하는 세션 그룹이 존재하지 않을 때 발생합니다.") + SESSION_GROUP_NOT_FOUND(20304, HttpStatus.NOT_FOUND, "존재하지 않는 세션 그룹입니다."), + + @ExplainError("THIS_AND_FUTURE 수정 범위에 이미 진행된(CLOSED) 세션이 포함될 때 발생합니다. force=true로 재요청하면 포함하여 수정합니다.") + CLOSED_SESSION_INCLUDED_IN_UPDATE( + 20305, + HttpStatus.CONFLICT, + "이미 진행된 세션이 수정 범위에 포함되어 있습니다. 계속하려면 force=true로 요청하세요.", + ), + + @ExplainError("THIS_AND_FUTURE 삭제 범위에 이미 진행된(CLOSED) 세션이 포함될 때 발생합니다. force=true로 재요청하면 포함하여 삭제합니다.") + CLOSED_SESSION_INCLUDED_IN_DELETE( + 20306, + HttpStatus.CONFLICT, + "이미 진행된 세션이 삭제 범위에 포함되어 있습니다. 계속하려면 force=true로 요청하세요.", + ), + + @ExplainError("반복 종료일이 시작일 기준 1년을 초과할 때 발생합니다.") + RECURRENCE_END_DATE_EXCEEDS_MAX(20307, HttpStatus.BAD_REQUEST, "반복 종료일은 시작일 기준 최대 1년까지 설정할 수 있습니다."), + + @ExplainError("종료 시간이 시작 시간보다 앞설 때 발생합니다. start만 변경할 경우 end도 함께 전달해야 합니다.") + END_BEFORE_START(20308, HttpStatus.BAD_REQUEST, "종료 시간은 시작 시간 이후여야 합니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionGroupNotFoundException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionGroupNotFoundException.kt new file mode 100644 index 00000000..fe36f7f0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionGroupNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class SessionGroupNotFoundException : BaseException(SessionErrorCode.SESSION_GROUP_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotFoundException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotFoundException.kt new file mode 100644 index 00000000..8e866880 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class SessionNotFoundException : BaseException(SessionErrorCode.SESSION_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotInProgressException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotInProgressException.kt new file mode 100644 index 00000000..1c7e82f8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotInProgressException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class SessionNotInProgressException : BaseException(SessionErrorCode.SESSION_NOT_IN_PROGRESS) diff --git a/src/main/kotlin/com/weeth/domain/session/application/mapper/SessionMapper.kt b/src/main/kotlin/com/weeth/domain/session/application/mapper/SessionMapper.kt new file mode 100644 index 00000000..d86be05a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/mapper/SessionMapper.kt @@ -0,0 +1,166 @@ +package com.weeth.domain.session.application.mapper + +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.schedule.domain.enums.Type +import com.weeth.domain.session.application.dto.request.SessionCreateRequest +import com.weeth.domain.session.application.dto.response.SessionGroupResponse +import com.weeth.domain.session.application.dto.response.SessionInfoResponse +import com.weeth.domain.session.application.dto.response.SessionInfosResponse +import com.weeth.domain.session.application.dto.response.SessionResponse +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.SessionGroup +import com.weeth.domain.session.domain.enums.SessionGroupStatus +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.service.RecurringSessionPolicy +import com.weeth.domain.user.domain.entity.User +import org.springframework.stereotype.Component +import java.time.LocalDate +import java.time.LocalDateTime + +@Component +class SessionMapper( + private val recurringSessionPolicy: RecurringSessionPolicy, +) { + fun toResponse(session: Session): SessionResponse = + SessionResponse( + id = session.id, + title = session.title, + content = session.content, + location = session.location, + name = session.user?.name, + cardinal = session.cardinal, + type = Type.SESSION, + code = null, + start = session.start, + end = session.end, + createdAt = session.createdAt, + modifiedAt = session.modifiedAt, + ) + + fun toAdminResponse(session: Session): SessionResponse = + SessionResponse( + id = session.id, + title = session.title, + content = session.content, + location = session.location, + name = session.user?.name, + cardinal = session.cardinal, + type = Type.SESSION, + code = session.code, + start = session.start, + end = session.end, + createdAt = session.createdAt, + modifiedAt = session.modifiedAt, + ) + + fun toInfo(session: Session): SessionInfoResponse = + SessionInfoResponse( + id = session.id, + cardinal = session.cardinal, + title = session.title, + start = session.start, + end = session.end, + status = session.status, + ) + + fun toGroupResponse( + group: SessionGroup, + sessions: List, + ): SessionGroupResponse { + val completedCount = sessions.count { it.status == SessionStatus.CLOSED } + val allCompleted = completedCount == sessions.size + val firstSession = sessions.minByOrNull { it.start } + return SessionGroupResponse( + groupId = group.id, + title = group.title, + recurrenceType = group.recurrenceType, + recurrenceDescription = + recurringSessionPolicy.buildRecurrenceDescription( + group.recurrenceType, + group.startTime, + firstSession?.start?.toLocalDate() ?: group.recurrenceEndDate, + ), + startDate = firstSession?.start?.toLocalDate(), + endDate = group.recurrenceEndDate, + completedCount = completedCount, + totalCount = sessions.size, + status = if (allCompleted) SessionGroupStatus.COMPLETED else SessionGroupStatus.IN_PROGRESS, + sessions = sessions.sortedBy { it.start }.map { toInfo(it) }, + ) + } + + fun toSingleGroupResponse(session: Session): SessionGroupResponse { + val completed = session.status == SessionStatus.CLOSED + return SessionGroupResponse( + groupId = null, + title = session.title, + recurrenceType = null, + recurrenceDescription = null, + startDate = session.start.toLocalDate(), + endDate = null, + completedCount = if (completed) 1 else 0, + totalCount = 1, + status = if (completed) SessionGroupStatus.COMPLETED else SessionGroupStatus.IN_PROGRESS, + sessions = listOf(toInfo(session)), + ) + } + + fun toInfos( + thisWeekSessions: List, + groupedSessions: List, + ): SessionInfosResponse = + SessionInfosResponse( + thisWeek = thisWeekSessions.map { toInfo(it) }, + sessions = groupedSessions, + ) + + fun toEntity( + club: Club, + request: SessionCreateRequest, + user: User, + ): Session = + Session.Companion.create( + club = club, + title = request.title, + content = request.content, + location = request.location, + cardinal = request.cardinal, + start = request.start, + end = request.end, + user = user, + ) + + fun toSessionGroup( + request: SessionCreateRequest, + recurrenceEndDate: LocalDate, + ): SessionGroup = + SessionGroup( + title = request.title, + recurrenceType = checkNotNull(request.recurrenceType), + recurrenceEndDate = recurrenceEndDate, + cardinal = request.cardinal, + startTime = request.start.toLocalTime(), + endTime = request.end.toLocalTime(), + ) + + fun toEntities( + club: Club, + request: SessionCreateRequest, + user: User, + sessionGroup: SessionGroup, + schedules: List>, + ): List = + schedules.map { (start, end) -> + Session.Companion.create( + club = club, + title = request.title, + content = request.content, + location = request.location, + cardinal = request.cardinal, + start = start, + end = end, + user = user, + sessionGroup = sessionGroup, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCase.kt new file mode 100644 index 00000000..ebb3e068 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCase.kt @@ -0,0 +1,110 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.session.application.dto.request.SessionCreateRequest +import com.weeth.domain.session.application.exception.RecurrenceEndDateBeforeStartException +import com.weeth.domain.session.application.exception.RecurrenceEndDateExceedsMaxException +import com.weeth.domain.session.application.exception.RecurrenceEndDateRequiredException +import com.weeth.domain.session.application.mapper.SessionMapper +import com.weeth.domain.session.domain.repository.SessionGroupRepository +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.domain.service.RecurringSessionPolicy +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CreateSessionUseCase( + private val sessionRepository: SessionRepository, + private val attendanceRepository: AttendanceRepository, + private val sessionGroupRepository: SessionGroupRepository, + private val userReader: UserReader, + private val cardinalReader: CardinalReader, + private val sessionMapper: SessionMapper, + private val clubReader: ClubReader, + private val clubMemberCardinalReader: ClubMemberCardinalReader, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val recurringSessionPolicy: RecurringSessionPolicy, +) { + @Transactional + fun create( + clubId: Long, + request: SessionCreateRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val club = clubReader.getClubById(clubId) + val user = userReader.getById(userId) + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw CardinalNotFoundException() + + val members = + clubMemberCardinalReader + .findAllByClubIdAndCardinalNumber(clubId, request.cardinal, MemberStatus.ACTIVE) + .map { it.clubMember } + + when (request.recurrenceType) { + null -> createSingleSession(club, request, user, members) + else -> createRecurringSessions(club, request, user, members) + } + } + + private fun createSingleSession( + club: Club, + request: SessionCreateRequest, + user: User, + members: List, + ) { + val session = sessionMapper.toEntity(club, request, user) + + sessionRepository.save(session) + attendanceRepository.saveAll(members.map { Attendance.create(session, it) }) + } + + /** + * 반복 세션 생성 메서드 + */ + private fun createRecurringSessions( + club: Club, + request: SessionCreateRequest, + user: User, + members: List, + ) { + val recurrenceType = checkNotNull(request.recurrenceType) + val startDate = request.start.toLocalDate() + val endDate = + request.recurrenceEndDate + ?: throw RecurrenceEndDateRequiredException() + + if (endDate.isBefore(startDate)) { + throw RecurrenceEndDateBeforeStartException() + } + if (endDate.isAfter(startDate.plusYears(1))) { + throw RecurrenceEndDateExceedsMaxException() + } + + val schedules = recurringSessionPolicy.calculateSchedules(request.start, request.end, recurrenceType, endDate) + if (schedules.isEmpty()) { + throw RecurrenceEndDateBeforeStartException() + } + + val group = sessionMapper.toSessionGroup(request, endDate) + sessionGroupRepository.save(group) + + val sessions = sessionMapper.toEntities(club, request, user, group, schedules) + sessionRepository.saveAll(sessions) + + val attendances = sessions.flatMap { session -> members.map { Attendance.create(session, it) } } + attendanceRepository.saveAll(attendances) + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCase.kt new file mode 100644 index 00000000..8a73084d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCase.kt @@ -0,0 +1,156 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.session.application.exception.ClosedSessionIncludedException +import com.weeth.domain.session.application.exception.SessionErrorCode +import com.weeth.domain.session.application.exception.SessionGroupNotFoundException +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.SessionGroup +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.enums.UpdateScope +import com.weeth.domain.session.domain.repository.SessionGroupRepository +import com.weeth.domain.session.domain.repository.SessionRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class DeleteSessionUseCase( + private val sessionRepository: SessionRepository, + private val attendanceRepository: AttendanceRepository, + private val sessionGroupRepository: SessionGroupRepository, + private val clubPermissionPolicy: ClubPermissionPolicy, +) { + @Transactional + fun delete( + clubId: Long, + sessionId: Long, + userId: Long, + scope: UpdateScope = UpdateScope.THIS_ONLY, + force: Boolean = false, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() + if (session.club.id != clubId) throw SessionNotFoundException() + + // 단일 세션인 경우 + if (!session.isRecurring) { + deleteSingleSession(session) + return + } + + val group = checkNotNull(session.sessionGroup) { "반복 세션인데 그룹이 없습니다" } + + // 반복 세션인 경우 + when (scope) { + UpdateScope.THIS_ONLY -> { + deleteSingleSession(session) + val lockedGroup = sessionGroupRepository.findByIdWithLock(group.id) ?: return + deleteGroupIfEmpty(lockedGroup) + } + + UpdateScope.THIS_AND_FUTURE -> { + val futureSessions = + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock( + group, + session.start, + ) + + validateNoClosedSessions(futureSessions, force) + + val attendances = + attendanceRepository.findAllBySessionInAndClubMemberMemberStatusWithLock( + futureSessions, + MemberStatus.ACTIVE, + ) + + rollbackAttendances(attendances) + attendanceRepository.deleteAllBySessionIn(futureSessions) + sessionRepository.deleteAll(futureSessions) + + val lockedGroup = sessionGroupRepository.findByIdWithLock(group.id) ?: return + deleteGroupIfEmpty(lockedGroup) + } + } + } + + private fun validateNoClosedSessions( + futureSessions: List, + force: Boolean, + ) { + if (!force) { + val closedCount = futureSessions.count { it.status == SessionStatus.CLOSED } + if (closedCount > 0) { + throw ClosedSessionIncludedException( + SessionErrorCode.CLOSED_SESSION_INCLUDED_IN_DELETE, + closedCount, + ) + } + } + } + + @Transactional + fun deleteGroup( + clubId: Long, + groupId: Long, + userId: Long, + force: Boolean = false, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val group = + sessionGroupRepository.findById(groupId).orElseThrow { SessionGroupNotFoundException() } + val sessions = sessionRepository.findAllBySessionGroupWithLock(group) + + if (sessions.isEmpty()) { + sessionGroupRepository.delete(group) + return + } + + if (sessions.first().club.id != clubId) throw SessionGroupNotFoundException() + + validateNoClosedSessions(sessions, force) + + val attendances = + attendanceRepository.findAllBySessionInAndClubMemberMemberStatusWithLock( + sessions, + MemberStatus.ACTIVE, + ) + + rollbackAttendances(attendances) + attendanceRepository.deleteAllBySessionIn(sessions) + sessionRepository.deleteAll(sessions) + sessionGroupRepository.delete(group) + } + + private fun deleteSingleSession(session: Session) { + val attendances = + attendanceRepository.findAllBySessionAndClubMemberMemberStatusWithLock(session, MemberStatus.ACTIVE) + rollbackAttendances(attendances) + + attendanceRepository.deleteAllBySession(session) + sessionRepository.delete(session) + } + + private fun rollbackAttendances(attendances: List) { + attendances.forEach { a -> + when (a.status) { + AttendanceStatus.ATTEND -> a.clubMember.removeAttend() + AttendanceStatus.ABSENT -> a.clubMember.removeAbsent() + else -> Unit + } + } + } + + private fun deleteGroupIfEmpty(group: SessionGroup) { + val remainingCount = sessionRepository.countBySessionGroup(group) + if (remainingCount == 0L) { + sessionGroupRepository.delete(group) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCase.kt new file mode 100644 index 00000000..00698c5a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCase.kt @@ -0,0 +1,114 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.session.application.dto.request.SessionUpdateRequest +import com.weeth.domain.session.application.exception.ClosedSessionIncludedException +import com.weeth.domain.session.application.exception.EndBeforeStartException +import com.weeth.domain.session.application.exception.SessionErrorCode +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.enums.UpdateScope +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.domain.service.RecurringSessionPolicy +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class UpdateSessionUseCase( + private val sessionRepository: SessionRepository, + private val userReader: UserReader, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val recurringSessionPolicy: RecurringSessionPolicy, +) { + @Transactional + fun update( + clubId: Long, + sessionId: Long, + request: SessionUpdateRequest, + userId: Long, + scope: UpdateScope = UpdateScope.THIS_ONLY, + force: Boolean = false, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() + if (session.club.id != clubId) throw SessionNotFoundException() + val user = userReader.getById(userId) + + val effectiveStart = request.start ?: session.start + val effectiveEnd = request.end ?: session.end + if (effectiveEnd.isBefore(effectiveStart)) throw EndBeforeStartException() + + if (!session.isRecurring || scope == UpdateScope.THIS_ONLY) { + updateSingleSession(session, request, effectiveStart, effectiveEnd, user) + } else { + updateRecurringSessions(session, request, effectiveStart, effectiveEnd, user, force) + } + } + + private fun updateSingleSession( + session: Session, + request: SessionUpdateRequest, + effectiveStart: LocalDateTime, + effectiveEnd: LocalDateTime, + user: User, + ) { + session.updateInfo( + title = request.title ?: session.title, + content = request.content ?: session.content, + location = request.location ?: session.location, + start = effectiveStart, + end = effectiveEnd, + user = user, + ) + } + + /** + * 반복 세션을 수정한다. + * 반복 수정을 하는 경우 세션/출석의 상태를 유지하기 위해 별도로 세션을 삭제/재생성 하지 않고, in-place로 갱신한다. + * 이 경우 반복 세션 중 특정 세션 이후의 시간을 미루는 경우 이전 날짜의 세션이 남아있을 수 있으나, 이는 사용자가 삭제할 수 있도록 유지한다. + */ + private fun updateRecurringSessions( + session: Session, + request: SessionUpdateRequest, + effectiveStart: LocalDateTime, + effectiveEnd: LocalDateTime, + user: User, + force: Boolean, + ) { + val group = checkNotNull(session.sessionGroup) { "반복 세션인데 그룹이 없습니다" } + val futureSessions = + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session.start) + + if (!force) { + val closedCount = futureSessions.count { it.status == SessionStatus.CLOSED } + if (closedCount > 0) { + throw ClosedSessionIncludedException(SessionErrorCode.CLOSED_SESSION_INCLUDED_IN_UPDATE, closedCount) + } + } + + val effectiveTitle = request.title ?: session.title + + futureSessions.forEach { s -> + val (start, end) = recurringSessionPolicy.adjustTime(s.start, effectiveStart, effectiveEnd) + s.updateInfo( + title = effectiveTitle, + content = request.content ?: s.content, + location = request.location ?: s.location, + start = start, + end = end, + user = user, + ) + } + + group.updateMetadata( + title = effectiveTitle, + startTime = effectiveStart.toLocalTime(), + endTime = effectiveEnd.toLocalTime(), + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt new file mode 100644 index 00000000..b021463b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt @@ -0,0 +1,97 @@ +package com.weeth.domain.session.application.usecase.query + +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.session.application.dto.response.SessionGroupResponse +import com.weeth.domain.session.application.dto.response.SessionInfosResponse +import com.weeth.domain.session.application.dto.response.SessionResponse +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.application.mapper.SessionMapper +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.repository.SessionRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters + +@Service +@Transactional(readOnly = true) +class GetSessionQueryService( + private val sessionRepository: SessionRepository, + private val cardinalReader: CardinalReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val sessionMapper: SessionMapper, +) { + fun findSession( + clubId: Long, + userId: Long, + sessionId: Long, + ): SessionResponse { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val session = sessionRepository.findByIdAndClubId(sessionId, clubId) ?: throw SessionNotFoundException() + + return if (member.isAdminOrLead()) { + sessionMapper.toAdminResponse(session) + } else { + sessionMapper.toResponse(session) + } + } + + fun findSessionInfos( + clubId: Long, + userId: Long, + cardinal: Int?, + ): SessionInfosResponse { + clubPermissionPolicy.requireAdmin(clubId, userId) + if (cardinal != null) { + cardinalReader.findByClubIdAndCardinalNumber(clubId, cardinal) + ?: throw CardinalNotFoundException() + } + val sessions = + if (cardinal == null) { + sessionRepository.findAllByClubIdOrderByStartDesc(clubId) + } else { + sessionRepository.findAllByClubIdAndCardinalOrderByStartDesc(clubId, cardinal) + } + + val thisWeek = findThisWeek(sessions) + val groupedResponses = buildGroupResponses(sessions) + + return sessionMapper.toInfos(thisWeek, groupedResponses) + } + + private fun buildGroupResponses(sessions: List): List { + // 반복 세션은 그룹별로 묶고, 비반복 세션은 개별로 처리 + val groupResponses = + sessions + .filter { it.isRecurring } + .groupBy { checkNotNull(it.sessionGroup).id } + .map { (_, groupSessions) -> + val group = checkNotNull(groupSessions.first().sessionGroup) + sessionMapper.toGroupResponse(group, groupSessions) + } + + val singleResponses = + sessions + .filter { !it.isRecurring } + .map { sessionMapper.toSingleGroupResponse(it) } + + // 시작일 기준 내림차순 정렬 + return (groupResponses + singleResponses).sortedByDescending { it.startDate } + } + + private fun findThisWeek(sessions: List): List { + val today = LocalDate.now() + val startOfWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val endOfWeek = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)) + + return sessions.filter { s -> + val d = s.start.toLocalDate() + !d.isBefore(startOfWeek) && !d.isAfter(endOfWeek) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt new file mode 100644 index 00000000..4df5bfd0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt @@ -0,0 +1,160 @@ +package com.weeth.domain.session.domain.entity + +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.security.SecureRandom +import java.time.LocalDateTime +import kotlin.random.asKotlinRandom + +@Entity +@Table( + name = "session", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_session_group_start", + columnNames = ["session_group_id", "start"], + ), + ], +) +class Session( + club: Club, + title: String, + content: String? = null, + location: String? = null, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + code: Int, + status: SessionStatus = SessionStatus.OPEN, + user: User? = null, + sessionGroup: SessionGroup? = null, +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + var club: Club = club + private set + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + var title: String = title + private set + + @Column(length = 500) + var content: String? = content + private set + + var location: String? = location + private set + + var cardinal: Int = cardinal + private set + + var start: LocalDateTime = start + private set + + var end: LocalDateTime = end + private set + + var code: Int = code + private set + + @Enumerated(EnumType.STRING) + var status: SessionStatus = status + private set + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + var user: User? = user + private set + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_group_id") + var sessionGroup: SessionGroup? = sessionGroup + private set + + val isRecurring: Boolean + get() = sessionGroup != null + + fun close() { + check(status == SessionStatus.OPEN) { "이미 종료된 세션입니다" } + status = SessionStatus.CLOSED + } + + fun updateInfo( + title: String, + content: String?, + location: String?, + start: LocalDateTime, + end: LocalDateTime, + user: User?, + ) { + require(title.isNotBlank()) { "제목은 필수입니다" } + require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } + this.title = title + this.content = content + this.location = location + this.start = start + this.end = end + this.user = user + } + + fun isCodeMatch(code: Int): Boolean = this.code == code + + fun isInProgress(now: LocalDateTime): Boolean = !now.isBefore(start) && !now.isAfter(end) + + fun isCheckInAllowed(now: LocalDateTime): Boolean { + val from = start.minusMinutes(10) + val to = end.plusMinutes(10) + return !now.isBefore(from) && !now.isAfter(to) + } + + companion object { + private val secureRandom = SecureRandom().asKotlinRandom() + + fun create( + club: Club, + title: String, + content: String?, + location: String?, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + user: User?, + sessionGroup: SessionGroup? = null, + ): Session { + require(title.isNotBlank()) { "제목은 필수입니다" } + require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } + return Session( + club = club, + title = title, + content = content, + location = location, + cardinal = cardinal, + start = start, + end = end, + code = generateCode(), + user = user, + sessionGroup = sessionGroup, + ) + } + + private fun generateCode(): Int = (100000..999999).random(secureRandom) + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/SessionGroup.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/SessionGroup.kt new file mode 100644 index 00000000..33c63dd0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/entity/SessionGroup.kt @@ -0,0 +1,62 @@ +package com.weeth.domain.session.domain.entity + +import com.weeth.domain.session.domain.enums.RecurrenceType +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDate +import java.time.LocalTime + +@Entity +@Table(name = "session_group") +class SessionGroup( + title: String, + recurrenceType: RecurrenceType, + recurrenceEndDate: LocalDate, + cardinal: Int, + startTime: LocalTime, + endTime: LocalTime, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + var title: String = title + private set + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var recurrenceType: RecurrenceType = recurrenceType + private set + + var recurrenceEndDate: LocalDate = recurrenceEndDate + private set + + var cardinal: Int = cardinal + private set + + // 반복 기준 시작 시각 + var startTime: LocalTime = startTime + private set + + // 반복 기준 종료 시각 + var endTime: LocalTime = endTime + private set + + fun updateMetadata( + title: String, + startTime: LocalTime, + endTime: LocalTime, + ) { + this.title = title + this.startTime = startTime + this.endTime = endTime + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/enums/RecurrenceType.kt b/src/main/kotlin/com/weeth/domain/session/domain/enums/RecurrenceType.kt new file mode 100644 index 00000000..211ce908 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/enums/RecurrenceType.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.session.domain.enums + +enum class RecurrenceType { + DAILY, + WEEKLY, + MONTHLY, +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionGroupStatus.kt b/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionGroupStatus.kt new file mode 100644 index 00000000..6a195079 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionGroupStatus.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.session.domain.enums + +enum class SessionGroupStatus { + COMPLETED, // 전체 종료 + IN_PROGRESS, // 진행 중 (미완료 세션 존재) +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionStatus.kt b/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionStatus.kt new file mode 100644 index 00000000..3ba23699 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionStatus.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.session.domain.enums + +enum class SessionStatus { + OPEN, + CLOSED, +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/enums/UpdateScope.kt b/src/main/kotlin/com/weeth/domain/session/domain/enums/UpdateScope.kt new file mode 100644 index 00000000..5d99ee17 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/enums/UpdateScope.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.session.domain.enums + +enum class UpdateScope { + THIS_ONLY, // 해당 세션만 + THIS_AND_FUTURE, // 해당 세션을 포함한 이후 세션 전체 +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionGroupRepository.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionGroupRepository.kt new file mode 100644 index 00000000..30bc6dc7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionGroupRepository.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.session.domain.repository + +import com.weeth.domain.session.domain.entity.SessionGroup +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +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.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param + +interface SessionGroupRepository : JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT sg FROM SessionGroup sg WHERE sg.id = :id") + fun findByIdWithLock( + @Param("id") id: Long, + ): SessionGroup? +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt new file mode 100644 index 00000000..b270b389 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt @@ -0,0 +1,39 @@ +package com.weeth.domain.session.domain.repository + +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.enums.SessionStatus +import java.time.LocalDateTime + +interface SessionReader { + fun getById(sessionId: Long): Session + + fun findByDateRange( + start: LocalDateTime, + end: LocalDateTime, + ): List + + fun findAllByCardinal(cardinal: Int): List + + fun findAllByCardinalOrderByStartAsc(cardinal: Int): List + + fun findAllByStatusAndEndBeforeOrderByEndAsc( + status: SessionStatus, + end: LocalDateTime, + ): List + + // TODO: QR 코드 출석 기능 구현 시 사용 예정 (현재 시간 기준 진행 중인 세션 조회) + fun findAllByClubIdAndStartBetween( + clubId: Long, + start: LocalDateTime, + end: LocalDateTime, + ): List + + fun findAllByClubIdAndCardinalIn( + clubId: Long, + cardinals: List, + ): List + + fun findOpenByClubId(clubId: Long): Session? + + fun findClubIdById(sessionId: Long): Long? +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt new file mode 100644 index 00000000..22796d31 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt @@ -0,0 +1,109 @@ +package com.weeth.domain.session.domain.repository + +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.SessionGroup +import com.weeth.domain.session.domain.enums.SessionStatus +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +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.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface SessionRepository : + JpaRepository, + SessionReader { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT s FROM Session s WHERE s.id = :id") + fun findByIdWithLock(id: Long): Session? + + fun findByIdAndClubId( + sessionId: Long, + clubId: Long, + ): Session? + + @Query("SELECT s FROM Session s LEFT JOIN FETCH s.sessionGroup WHERE s.club.id = :clubId ORDER BY s.start DESC") + fun findAllByClubIdOrderByStartDesc( + @Param("clubId") clubId: Long, + ): List + + @Query( + "SELECT s FROM Session s LEFT JOIN FETCH s.sessionGroup WHERE s.club.id = :clubId AND s.cardinal = :cardinal ORDER BY s.start DESC", + ) + fun findAllByClubIdAndCardinalOrderByStartDesc( + @Param("clubId") clubId: Long, + @Param("cardinal") cardinal: Int, + ): List + + override fun findAllByCardinalOrderByStartAsc(cardinal: Int): List + + override fun findAllByCardinal(cardinal: Int): List + + override fun findAllByStatusAndEndBeforeOrderByEndAsc( + status: SessionStatus, + end: LocalDateTime, + ): List + + override fun getById(sessionId: Long): Session = findById(sessionId).orElseThrow { SessionNotFoundException() } + + @Query("SELECT s FROM Session s WHERE s.club.id = :clubId AND s.start <= :end AND s.end >= :start") + override fun findAllByClubIdAndStartBetween( + @Param("clubId") clubId: Long, + @Param("start") start: LocalDateTime, + @Param("end") end: LocalDateTime, + ): List + + fun findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( + end: LocalDateTime, + start: LocalDateTime, + ): List + + override fun findByDateRange( + start: LocalDateTime, + end: LocalDateTime, + ): List = findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start) + + override fun findAllByClubIdAndCardinalIn( + clubId: Long, + cardinals: List, + ): List + + fun findFirstByClubIdAndStatusOrderByIdAsc( + clubId: Long, + status: SessionStatus, + ): Session? + + // OPEN 세션이 복수인 경우 id가 가장 작은 것 반환 + override fun findOpenByClubId(clubId: Long): Session? = + findFirstByClubIdAndStatusOrderByIdAsc(clubId, SessionStatus.OPEN) + + @Query("SELECT s.club.id FROM Session s WHERE s.id = :sessionId") + override fun findClubIdById( + @Param("sessionId") sessionId: Long, + ): Long? + + // 기준 시작시각 이후 세션 조회 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + "SELECT s FROM Session s WHERE s.sessionGroup = :group AND s.start >= :start ORDER BY s.start ASC, s.id ASC", + ) + fun findAllBySessionGroupAndStartGreaterThanEqualWithLock( + @Param("group") group: SessionGroup, + @Param("start") start: LocalDateTime, + ): List + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT s FROM Session s WHERE s.sessionGroup = :group ORDER BY s.start ASC, s.id ASC") + fun findAllBySessionGroupWithLock( + @Param("group") group: SessionGroup, + ): List + + // 세션 그룹의 남은 세션 수 조회 (삭제 후 빈 그룹 정리용) + fun countBySessionGroup(group: SessionGroup): Long +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicy.kt b/src/main/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicy.kt new file mode 100644 index 00000000..c55747c3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicy.kt @@ -0,0 +1,98 @@ +package com.weeth.domain.session.domain.service + +import com.weeth.domain.session.domain.enums.RecurrenceType +import org.springframework.stereotype.Service +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +@Service +class RecurringSessionPolicy { + fun adjustTime( + originalStart: LocalDateTime, + newStart: LocalDateTime, + newEnd: LocalDateTime, + ): Pair { + val startTime = newStart.toLocalTime() + val duration = Duration.between(newStart, newEnd) + val start = LocalDateTime.of(originalStart.toLocalDate(), startTime) + + return start to start.plus(duration) + } + + /** + * 반복 세션의 시작/종료 시각 쌍을 계산한다. + */ + fun calculateSchedules( + startDateTime: LocalDateTime, + endDateTime: LocalDateTime, + recurrenceType: RecurrenceType, + recurrenceEndDate: LocalDate, + ): List> { + val startDate = startDateTime.toLocalDate() + val schedules = mutableListOf>() + var index = 0 + + while (true) { + val currentDate = + when (recurrenceType) { + // startDate.plusMonths(n) 방식으로 1/31 → 2/28 → 3/31 대응 + RecurrenceType.MONTHLY -> startDate.plusMonths(index.toLong()) + + RecurrenceType.WEEKLY -> startDate.plusWeeks(index.toLong()) + + RecurrenceType.DAILY -> startDate.plusDays(index.toLong()) + } + if (currentDate.isAfter(recurrenceEndDate)) break + + val base = LocalDateTime.of(currentDate, startDateTime.toLocalTime()) + val (start, end) = adjustTime(base, startDateTime, endDateTime) + schedules.add(start to end) + index++ + } + + return schedules + } + + /** + * 반복 유형과 기준 날짜로 사람이 읽을 수 있는 설명 문자열을 생성한다. + * ex) "매일 14시", "매주 수요일 14시", "매월 15일 14시" + */ + fun buildRecurrenceDescription( + recurrenceType: RecurrenceType, + startTime: LocalTime, + baseDate: LocalDate, + ): String { + val timeStr = + if (startTime.minute == 0) { + startTime.format(DateTimeFormatter.ofPattern("H시")) + } else { + startTime.format(DateTimeFormatter.ofPattern("H시 m분")) + } + return when (recurrenceType) { + RecurrenceType.DAILY -> { + "매일 $timeStr" + } + + RecurrenceType.WEEKLY -> { + val dayOfWeek = + when (baseDate.dayOfWeek.value) { + 1 -> "월요일" + 2 -> "화요일" + 3 -> "수요일" + 4 -> "목요일" + 5 -> "금요일" + 6 -> "토요일" + else -> "일요일" + } + "매주 $dayOfWeek $timeStr" + } + + RecurrenceType.MONTHLY -> { + "매월 ${baseDate.dayOfMonth}일 $timeStr" + } + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt new file mode 100644 index 00000000..12875256 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt @@ -0,0 +1,116 @@ +package com.weeth.domain.session.presentation + +import com.weeth.domain.session.application.dto.request.SessionCreateRequest +import com.weeth.domain.session.application.dto.request.SessionUpdateRequest +import com.weeth.domain.session.application.dto.response.SessionInfosResponse +import com.weeth.domain.session.application.exception.SessionErrorCode +import com.weeth.domain.session.application.usecase.command.CreateSessionUseCase +import com.weeth.domain.session.application.usecase.command.DeleteSessionUseCase +import com.weeth.domain.session.application.usecase.command.UpdateSessionUseCase +import com.weeth.domain.session.application.usecase.query.GetSessionQueryService +import com.weeth.domain.session.domain.enums.UpdateScope +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +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 + +@Tag(name = "SESSION ADMIN", description = "[ADMIN] 정기모임 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/clubs/{clubId}/sessions") +@ApiErrorCodeExample(SessionErrorCode::class) +class SessionAdminController( + private val createSessionUseCase: CreateSessionUseCase, + private val updateSessionUseCase: UpdateSessionUseCase, + private val deleteSessionUseCase: DeleteSessionUseCase, + private val getSessionQueryService: GetSessionQueryService, +) { + @PostMapping + @Operation(summary = "정기모임 생성 (반복 지원)") + fun create( + @TsidParam + @TsidPathVariable clubId: Long, + @Valid @RequestBody dto: SessionCreateRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + createSessionUseCase.create(clubId, dto, userId) + return CommonResponse.success(SessionResponseCode.SESSION_SAVE_SUCCESS) + } + + @PatchMapping("/{sessionId}") + @Operation( + summary = "정기모임 수정", + description = "scope=THIS_AND_FUTURE 시 이후 전체 세션 수정. CLOSED 세션 포함 시 force=true로 재요청 필요", + ) + fun update( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable sessionId: Long, + @Valid @RequestBody dto: SessionUpdateRequest, + @RequestParam(defaultValue = "THIS_ONLY") scope: UpdateScope, + @RequestParam(defaultValue = "false") force: Boolean, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + updateSessionUseCase.update(clubId, sessionId, dto, userId, scope, force) + return CommonResponse.success(SessionResponseCode.SESSION_UPDATE_SUCCESS) + } + + @DeleteMapping("/{sessionId}") + @Operation( + summary = "정기모임 삭제", + description = "scope=THIS_AND_FUTURE 시 이후 전체 세션 삭제. CLOSED 세션 포함 시 force=true로 재요청 필요", + ) + fun delete( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable sessionId: Long, + @RequestParam(defaultValue = "THIS_ONLY") scope: UpdateScope, + @RequestParam(defaultValue = "false") force: Boolean, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + deleteSessionUseCase.delete(clubId, sessionId, userId, scope, force) + return CommonResponse.success(SessionResponseCode.SESSION_DELETE_SUCCESS) + } + + @DeleteMapping("/groups/{groupId}") + @Operation( + summary = "세션 그룹 전체 삭제", + description = "반복 세션 그룹과 소속 세션을 모두 삭제. CLOSED 세션 포함 시 force=true로 재요청 필요", + ) + fun deleteGroup( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable groupId: Long, + @RequestParam(defaultValue = "false") force: Boolean, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + deleteSessionUseCase.deleteGroup(clubId, groupId, userId, force) + return CommonResponse.success(SessionResponseCode.SESSION_DELETE_SUCCESS) + } + + @GetMapping + @Operation(summary = "정기모임 목록 조회 (반복 그룹 단위)") + fun getSessionInfos( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestParam(required = false) cardinal: Int?, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + SessionResponseCode.SESSION_INFOS_FIND_SUCCESS, + getSessionQueryService.findSessionInfos(clubId, userId, cardinal), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt new file mode 100644 index 00000000..42d8cbcf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt @@ -0,0 +1,38 @@ +package com.weeth.domain.session.presentation + +import com.weeth.domain.session.application.dto.response.SessionResponse +import com.weeth.domain.session.application.exception.SessionErrorCode +import com.weeth.domain.session.application.usecase.query.GetSessionQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "SESSION", description = "정기모임 API") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/sessions") +@ApiErrorCodeExample(SessionErrorCode::class) +class SessionController( + private val getSessionQueryService: GetSessionQueryService, +) { + @GetMapping("/{sessionId}") + @Operation(summary = "정기모임 상세 조회") + fun getSession( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @PathVariable sessionId: Long, + ): CommonResponse = + CommonResponse.success( + SessionResponseCode.SESSION_FIND_SUCCESS, + getSessionQueryService.findSession(clubId, userId, sessionId), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionResponseCode.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionResponseCode.kt new file mode 100644 index 00000000..50c42980 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionResponseCode.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.session.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class SessionResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + // SessionAdminController 관련 + SESSION_INFOS_FIND_SUCCESS(10300, HttpStatus.OK, "기수별 정기모임 리스트를 성공적으로 조회했습니다."), + SESSION_SAVE_SUCCESS(10301, HttpStatus.OK, "정기모임이 성공적으로 생성되었습니다."), + SESSION_UPDATE_SUCCESS(10302, HttpStatus.OK, "정기모임이 성공적으로 수정되었습니다."), + SESSION_DELETE_SUCCESS(10303, HttpStatus.OK, "정기모임이 성공적으로 삭제되었습니다."), + + // SessionController 관련 + SESSION_FIND_SUCCESS(10304, HttpStatus.OK, "정기모임이 성공적으로 조회되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/university/application/dto/response/MajorResponse.kt b/src/main/kotlin/com/weeth/domain/university/application/dto/response/MajorResponse.kt new file mode 100644 index 00000000..4282dd6e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/dto/response/MajorResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.university.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class MajorResponse( + @field:Schema(description = "학과명", example = "컴퓨터공학과") + val majorName: String, + @field:Schema(description = "계열", example = "공학계열") + val category: String, +) diff --git a/src/main/kotlin/com/weeth/domain/university/application/dto/response/SchoolResponse.kt b/src/main/kotlin/com/weeth/domain/university/application/dto/response/SchoolResponse.kt new file mode 100644 index 00000000..ad2b7275 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/dto/response/SchoolResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.university.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class SchoolResponse( + @field:Schema(description = "학교명", example = "가천대학교") + val schoolName: String, + @field:Schema(description = "지역", example = "경기도") + val region: String, +) diff --git a/src/main/kotlin/com/weeth/domain/university/application/exception/CareerNetApiException.kt b/src/main/kotlin/com/weeth/domain/university/application/exception/CareerNetApiException.kt new file mode 100644 index 00000000..5b1a9b43 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/exception/CareerNetApiException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.university.application.exception + +import com.weeth.global.common.exception.BaseException + +class CareerNetApiException : BaseException(UniversityErrorCode.CAREER_NET_API_ERROR) diff --git a/src/main/kotlin/com/weeth/domain/university/application/exception/UniversityErrorCode.kt b/src/main/kotlin/com/weeth/domain/university/application/exception/UniversityErrorCode.kt new file mode 100644 index 00000000..1b2d993f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/exception/UniversityErrorCode.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.university.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class UniversityErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("커리어넷 Open API 호출에 실패했을 때 발생합니다.") + CAREER_NET_API_ERROR(31300, HttpStatus.INTERNAL_SERVER_ERROR, "학교/학과 정보를 불러오는데 실패했습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/university/application/mapper/UniversityMapper.kt b/src/main/kotlin/com/weeth/domain/university/application/mapper/UniversityMapper.kt new file mode 100644 index 00000000..f17909e8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/mapper/UniversityMapper.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.university.application.mapper + +import com.weeth.domain.university.application.dto.response.MajorResponse +import com.weeth.domain.university.application.dto.response.SchoolResponse +import com.weeth.domain.university.domain.model.MajorData +import com.weeth.domain.university.domain.model.SchoolData +import org.springframework.stereotype.Component + +@Component +class UniversityMapper { + fun toSchoolResponse(data: SchoolData): SchoolResponse = + SchoolResponse( + schoolName = data.name, + region = data.region, + ) + + fun toMajorResponse(data: MajorData): MajorResponse = + MajorResponse( + majorName = data.name, + category = data.category, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryService.kt b/src/main/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryService.kt new file mode 100644 index 00000000..c3382fc0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryService.kt @@ -0,0 +1,34 @@ +package com.weeth.domain.university.application.usecase.query + +import com.weeth.domain.university.application.dto.response.MajorResponse +import com.weeth.domain.university.application.dto.response.SchoolResponse +import com.weeth.domain.university.application.mapper.UniversityMapper +import com.weeth.domain.university.domain.port.UniversityInfoPort +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service + +@Service +class GetUniversityQueryService( + private val universityInfoPort: UniversityInfoPort, + private val universityMapper: UniversityMapper, +) { + @Cacheable(value = ["schools"], key = "'all'") + fun getSchools(): List = + universityInfoPort + .getSchools() + .sortedWith(koreanFirstComparator { it.name }) + .map(universityMapper::toSchoolResponse) + + @Cacheable(value = ["majors"], key = "'all'") + fun getMajors(): List = + universityInfoPort + .getMajors() + .sortedWith(koreanFirstComparator { it.name }) + .map(universityMapper::toMajorResponse) + + private fun koreanFirstComparator(selector: (T) -> String): Comparator = + compareBy( + { selector(it).firstOrNull()?.let { c -> c !in '가'..'힣' } ?: true }, + { selector(it) }, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/university/domain/model/MajorData.kt b/src/main/kotlin/com/weeth/domain/university/domain/model/MajorData.kt new file mode 100644 index 00000000..fe271dd7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/domain/model/MajorData.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.university.domain.model + +data class MajorData( + val name: String, + val category: String, +) diff --git a/src/main/kotlin/com/weeth/domain/university/domain/model/SchoolData.kt b/src/main/kotlin/com/weeth/domain/university/domain/model/SchoolData.kt new file mode 100644 index 00000000..f1213089 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/domain/model/SchoolData.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.university.domain.model + +data class SchoolData( + val name: String, + val region: String, +) diff --git a/src/main/kotlin/com/weeth/domain/university/domain/port/UniversityInfoPort.kt b/src/main/kotlin/com/weeth/domain/university/domain/port/UniversityInfoPort.kt new file mode 100644 index 00000000..6def3f4f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/domain/port/UniversityInfoPort.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.university.domain.port + +import com.weeth.domain.university.domain.model.MajorData +import com.weeth.domain.university.domain.model.SchoolData + +interface UniversityInfoPort { + fun getSchools(): List + + fun getMajors(): List +} diff --git a/src/main/kotlin/com/weeth/domain/university/infrastructure/CareerNetAdapter.kt b/src/main/kotlin/com/weeth/domain/university/infrastructure/CareerNetAdapter.kt new file mode 100644 index 00000000..6cfa2c66 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/infrastructure/CareerNetAdapter.kt @@ -0,0 +1,122 @@ +package com.weeth.domain.university.infrastructure + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.weeth.domain.university.application.exception.CareerNetApiException +import com.weeth.domain.university.domain.model.MajorData +import com.weeth.domain.university.domain.model.SchoolData +import com.weeth.domain.university.domain.port.UniversityInfoPort +import com.weeth.global.config.properties.CareerNetProperties +import org.slf4j.LoggerFactory +import org.springframework.core.ParameterizedTypeReference +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient + +@Component +class CareerNetAdapter( + private val properties: CareerNetProperties, + restClientBuilder: RestClient.Builder, +) : UniversityInfoPort { + private val restClient = + restClientBuilder + .baseUrl(properties.baseUrl) + .build() + + companion object { + private const val SVC_TYPE = "api" + private const val GUBUN = "univ_list" + private const val CONTENT_TYPE = "json" + private const val PER_PAGE = 100 + private val log = LoggerFactory.getLogger(CareerNetAdapter::class.java) + } + + override fun getSchools(): List = + fetchAllPages(::fetchSchoolPage) + .map { SchoolData(it.schoolName, it.region) } + + override fun getMajors(): List = + fetchAllPages(::fetchMajorPage) + .map { MajorData(it.mClass, it.lClass) } + + private fun fetchAllPages(fetchPage: (Int) -> List): List { + val firstPage = fetchPage(1) + val totalCount = firstPage.firstOrNull()?.totalCount?.toIntOrNull() ?: 0 + val totalPages = ((totalCount + PER_PAGE - 1) / PER_PAGE).coerceAtLeast(1) + return firstPage + (2..totalPages).flatMap(fetchPage) + } + + private fun fetchSchoolPage(page: Int): List = + runCatching { + restClient + .get() + .uri { builder -> + builder + .queryParam("apiKey", properties.key) + .queryParam("contentType", CONTENT_TYPE) + .queryParam("svcType", SVC_TYPE) + .queryParam("svcCode", "SCHOOL") + .queryParam("gubun", GUBUN) + .queryParam("thisPage", page) + .queryParam("perPage", PER_PAGE) + .build() + }.retrieve() + .body(object : ParameterizedTypeReference>() {}) + ?.dataSearch + ?.content + ?: emptyList() + }.getOrElse { e -> + log.error("커리어넷 학교 목록 조회 실패", e) + throw CareerNetApiException() + } + + private fun fetchMajorPage(page: Int): List = + runCatching { + restClient + .get() + .uri { builder -> + builder + .queryParam("apiKey", properties.key) + .queryParam("contentType", CONTENT_TYPE) + .queryParam("svcType", SVC_TYPE) + .queryParam("svcCode", "MAJOR") + .queryParam("gubun", GUBUN) + .queryParam("thisPage", page) + .queryParam("perPage", PER_PAGE) + .build() + }.retrieve() + .body(object : ParameterizedTypeReference>() {}) + ?.dataSearch + ?.content + ?: emptyList() + }.getOrElse { e -> + log.error("커리어넷 학과 목록 조회 실패", e) + throw CareerNetApiException() + } +} + +internal interface CareerNetItem { + val totalCount: String +} + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class CareerNetResponse( + val dataSearch: DataSearch?, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class DataSearch( + val content: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class CareerNetSchoolItem( + val schoolName: String, + val region: String, + override val totalCount: String, +) : CareerNetItem + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class CareerNetMajorItem( + val lClass: String, + val mClass: String, + override val totalCount: String, +) : CareerNetItem diff --git a/src/main/kotlin/com/weeth/domain/university/presentation/UniversityController.kt b/src/main/kotlin/com/weeth/domain/university/presentation/UniversityController.kt new file mode 100644 index 00000000..9af6ef96 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/presentation/UniversityController.kt @@ -0,0 +1,36 @@ +package com.weeth.domain.university.presentation + +import com.weeth.domain.university.application.dto.response.MajorResponse +import com.weeth.domain.university.application.dto.response.SchoolResponse +import com.weeth.domain.university.application.exception.UniversityErrorCode +import com.weeth.domain.university.application.usecase.query.GetUniversityQueryService +import com.weeth.domain.university.presentation.UniversityResponseCode.MAJOR_FIND_ALL_SUCCESS +import com.weeth.domain.university.presentation.UniversityResponseCode.SCHOOL_FIND_ALL_SUCCESS +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.security.SecurityRequirements +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "UNIVERSITY", description = "학교/학과 API") +@RestController +@RequestMapping("/api/v4/university") +@ApiErrorCodeExample(UniversityErrorCode::class) +class UniversityController( + private val getUniversityQueryService: GetUniversityQueryService, +) { + @GetMapping("/schools") + @Operation(summary = "학교 목록 조회") + @SecurityRequirements + fun getSchools(): CommonResponse> = + CommonResponse.success(SCHOOL_FIND_ALL_SUCCESS, getUniversityQueryService.getSchools()) + + @GetMapping("/majors") + @Operation(summary = "학과 목록 조회") + @SecurityRequirements + fun getMajors(): CommonResponse> = + CommonResponse.success(MAJOR_FIND_ALL_SUCCESS, getUniversityQueryService.getMajors()) +} diff --git a/src/main/kotlin/com/weeth/domain/university/presentation/UniversityResponseCode.kt b/src/main/kotlin/com/weeth/domain/university/presentation/UniversityResponseCode.kt new file mode 100644 index 00000000..3e53f4ca --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/presentation/UniversityResponseCode.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.university.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class UniversityResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + SCHOOL_FIND_ALL_SUCCESS(11300, HttpStatus.OK, "학교 목록을 성공적으로 조회했습니다."), + MAJOR_FIND_ALL_SUCCESS(11301, HttpStatus.OK, "학과 목록을 성공적으로 조회했습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/AgreeTermsRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/AgreeTermsRequest.kt new file mode 100644 index 00000000..23149522 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/AgreeTermsRequest.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.AssertTrue + +data class AgreeTermsRequest( + @field:Schema(description = "서비스 이용약관 동의", example = "true") + @field:AssertTrue(message = "서비스 이용약관에 동의해야 합니다") + val termsAgreed: Boolean, + @field:Schema(description = "개인정보 처리방침 동의", example = "true") + @field:AssertTrue(message = "개인정보 처리방침에 동의해야 합니다") + val privacyAgreed: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt new file mode 100644 index 00000000..3fda4406 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class CreateInquiryRequest( + @field:Schema(description = "이메일", example = "user@example.com") + @field:NotBlank + @field:Email + @field:Size(max = 255) + val email: String, + @field:Schema(description = "문의 내용", example = "서비스에 대해 문의드립니다.") + @field:Size(max = 1000) + val message: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/SocialLoginRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/SocialLoginRequest.kt new file mode 100644 index 00000000..e8c609ff --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/SocialLoginRequest.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +data class SocialLoginRequest( + @field:Schema(description = "OAuth2 인가 코드(auth code)", example = "SplxlOBeZQQYbYS6WxSbIA") + @field:NotBlank + val authCode: String, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt new file mode 100644 index 00000000..c11edb13 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class UpdateUserProfileRequest( + @field:Schema(description = "이름", example = "홍길동") + @field:Size(min = 1, max = 20) + val name: String? = null, + @field:Schema(description = "이메일", example = "hong@example.com") + @field:Size(min = 1) + @field:Email + val email: String? = null, + @field:Schema(description = "학번", example = "20201234") + @field:Size(min = 1) + val studentId: String? = null, + @field:Schema(description = "전화번호", example = "01012345678") + @field:Size(min = 1) + val tel: String? = null, + @field:Schema(description = "학교", example = "가천대학교") + @field:Size(min = 1) + val school: String? = null, + @field:Schema(description = "학과", example = "컴퓨터공학과") + @field:Size(min = 1) + val department: String? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserIdsRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserIdsRequest.kt new file mode 100644 index 00000000..3cdbba2a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserIdsRequest.kt @@ -0,0 +1,11 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.NotNull + +data class UserIdsRequest( + @field:Schema(description = "처리 대상 사용자 ID 목록", example = "[1, 2, 3]") + @field:NotEmpty + val userId: List, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt new file mode 100644 index 00000000..d3f575b6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt @@ -0,0 +1,11 @@ +package com.weeth.domain.user.application.dto.request + +import com.weeth.domain.club.domain.enums.MemberRole +import io.swagger.v3.oas.annotations.media.Schema + +data class UserRoleUpdateRequest( + @field:Schema(description = "대상 사용자 ID", example = "1") + val userId: Long, + @field:Schema(description = "변경할 동아리 내 권한", example = "ADMIN") + val role: MemberRole, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt new file mode 100644 index 00000000..91c29170 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.user.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class SocialLoginResponse( + @field:Schema(description = "사용자 이름") + val name: String, + @field:Schema(description = "액세스 토큰") + val accessToken: String, + @field:Schema(description = "리프레시 토큰") + val refreshToken: String, + @field:Schema(description = "약관 동의 완료 여부 (true: 약관 동의 완료, false: 약관 동의 필요)", example = "true") + val registered: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfo.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfo.kt new file mode 100644 index 00000000..d677a32c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfo.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.user.application.dto.response + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.user.domain.entity.User +import io.swagger.v3.oas.annotations.media.Schema + +data class UserInfo( + @field:Schema(description = "사용자 ID", example = "1") + val id: Long, + @field:Schema(description = "이름", example = "홍길동") + val name: String, + @field:Schema(description = "프로필 이미지 URL") + val profileImageUrl: String?, + @field:Schema(description = "동아리 내 권한", example = "USER") + val role: MemberRole, +) { + companion object { + fun of( + user: User, + role: MemberRole, + resolvedProfileImageUrl: String?, + ) = UserInfo( + id = user.id, + name = user.name, + profileImageUrl = resolvedProfileImageUrl, + role = role, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/EmailNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/EmailNotFoundException.kt new file mode 100644 index 00000000..0c94746c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/EmailNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class EmailNotFoundException : BaseException(UserErrorCode.EMAIL_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/InvalidUserOrderException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/InvalidUserOrderException.kt new file mode 100644 index 00000000..635e0293 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/InvalidUserOrderException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class InvalidUserOrderException : BaseException(UserErrorCode.INVALID_USER_ORDER) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/PasswordMismatchException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/PasswordMismatchException.kt new file mode 100644 index 00000000..3e46d078 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/PasswordMismatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class PasswordMismatchException : BaseException(UserErrorCode.PASSWORD_MISMATCH) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/ProfileRequiredFieldsMissingException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/ProfileRequiredFieldsMissingException.kt new file mode 100644 index 00000000..44b6e030 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/ProfileRequiredFieldsMissingException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class ProfileRequiredFieldsMissingException : BaseException(UserErrorCode.PROFILE_REQUIRED_FIELDS_MISSING) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/RoleNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/RoleNotFoundException.kt new file mode 100644 index 00000000..c2608604 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/RoleNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class RoleNotFoundException : BaseException(UserErrorCode.ROLE_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/StatusNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/StatusNotFoundException.kt new file mode 100644 index 00000000..d1f7c1c3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/StatusNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class StatusNotFoundException : BaseException(UserErrorCode.STATUS_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/StudentIdExistsException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/StudentIdExistsException.kt new file mode 100644 index 00000000..f5a74190 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/StudentIdExistsException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class StudentIdExistsException : BaseException(UserErrorCode.STUDENT_ID_EXISTS) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/TelExistsException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/TelExistsException.kt new file mode 100644 index 00000000..aaf8d445 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/TelExistsException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class TelExistsException : BaseException(UserErrorCode.TEL_EXISTS) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt new file mode 100644 index 00000000..80291859 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt @@ -0,0 +1,50 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class UserErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") + USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), + + @ExplainError("가입 승인 대기 중인 사용자가 접근을 시도할 때 발생합니다.") + USER_INACTIVE(20901, HttpStatus.FORBIDDEN, "가입 승인이 허가되지 않은 계정입니다."), + + @ExplainError("이미 가입된 이메일로 회원가입을 시도할 때 발생합니다.") + USER_EXISTS(20902, HttpStatus.BAD_REQUEST, "이미 가입된 사용자입니다."), + + @ExplainError("요청한 사용자 정보와 실제 사용자 정보가 일치하지 않을 때 발생합니다.") + USER_MISMATCH(20903, HttpStatus.FORBIDDEN, "사용자 정보가 일치하지 않습니다."), + + @ExplainError("다른 사용자의 리소스에 접근하려고 할 때 발생합니다.") + USER_NOT_MATCH(20904, HttpStatus.FORBIDDEN, "해당 사용자가 아닙니다."), + + @ExplainError("로그인 시 비밀번호가 일치하지 않을 때 발생합니다.") + PASSWORD_MISMATCH(20905, HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), + + @ExplainError("입력한 이메일로 등록된 사용자가 없을 때 발생합니다.") + EMAIL_NOT_FOUND(20906, HttpStatus.NOT_FOUND, "이메일을 찾을 수 없습니다."), + + @ExplainError("이미 등록된 학번으로 회원가입을 시도할 때 발생합니다.") + STUDENT_ID_EXISTS(20907, HttpStatus.BAD_REQUEST, "이미 존재하는 학번입니다."), + + @ExplainError("이미 등록된 전화번호로 회원가입을 시도할 때 발생합니다.") + TEL_EXISTS(20908, HttpStatus.BAD_REQUEST, "이미 존재하는 전화번호입니다."), + + @ExplainError("잘못된 권한 값이 입력되었을 때 발생합니다.") + ROLE_NOT_FOUND(20909, HttpStatus.BAD_REQUEST, "권한을 찾을 수 없습니다."), + + @ExplainError("잘못된 상태 값이 입력되었을 때 발생합니다.") + STATUS_NOT_FOUND(20910, HttpStatus.BAD_REQUEST, "상태를 찾을 수 없습니다."), + + @ExplainError("사용자 순서 지정 시 잘못된 값이 입력되었을 때 발생합니다.") + INVALID_USER_ORDER(20911, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."), + + @ExplainError("프로필 초기 설정 시 필수 필드가 누락되었을 때 발생합니다.") + PROFILE_REQUIRED_FIELDS_MISSING(20912, HttpStatus.BAD_REQUEST, "프로필 초기 설정 시 모든 필수 항목을 입력해야 합니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserExistsException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserExistsException.kt new file mode 100644 index 00000000..ece5509d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserExistsException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserExistsException : BaseException(UserErrorCode.USER_EXISTS) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserInActiveException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserInActiveException.kt new file mode 100644 index 00000000..5f639651 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserInActiveException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserInActiveException : BaseException(UserErrorCode.USER_INACTIVE) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserMismatchException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserMismatchException.kt new file mode 100644 index 00000000..20db334e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserMismatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserMismatchException : BaseException(UserErrorCode.USER_MISMATCH) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotFoundException.kt new file mode 100644 index 00000000..9ff4caf9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserNotFoundException : BaseException(UserErrorCode.USER_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotMatchException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotMatchException.kt new file mode 100644 index 00000000..d058919e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotMatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserNotMatchException : BaseException(UserErrorCode.USER_NOT_MATCH) diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt new file mode 100644 index 00000000..4a317510 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.user.application.mapper + +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.user.application.dto.response.SocialLoginResponse +import com.weeth.global.auth.jwt.application.dto.JwtDto +import org.springframework.stereotype.Component + +@Component +class UserMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { + fun toSocialLoginResponse( + userName: String, + token: JwtDto, + registered: Boolean, + ): SocialLoginResponse = + SocialLoginResponse( + name = userName, + accessToken = token.accessToken, + refreshToken = token.refreshToken, + registered = registered, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCase.kt new file mode 100644 index 00000000..aef8bec5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCase.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.AgreeTermsRequest +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.jwt.domain.enums.TokenType +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AgreeTermsUseCase( + private val userRepository: UserRepository, + private val jwtManageUseCase: JwtManageUseCase, +) { + @Transactional + fun execute( + userId: Long, + request: AgreeTermsRequest, + ): JwtDto { + val user = userRepository.getById(userId) + user.agreeTerms(request.termsAgreed, request.privacyAgreed) + user.accept() // 약관 동의시 회원가입 승인 + + return jwtManageUseCase.create(userId, user.emailValue, TokenType.ACCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt new file mode 100644 index 00000000..2936caf0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import jakarta.servlet.http.HttpServletRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AuthUserUseCase( + private val userReader: UserReader, + private val jwtManageUseCase: JwtManageUseCase, + private val jwtTokenExtractor: JwtTokenExtractor, +) { + @Transactional + fun leave(userId: Long) { + val user = userReader.getById(userId) + user.leave() + } + + fun refreshToken(httpServletRequest: HttpServletRequest): JwtDto { + val refreshToken = jwtTokenExtractor.extractRefreshToken(httpServletRequest) + return jwtManageUseCase.reIssueToken(refreshToken) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt new file mode 100644 index 00000000..bd1c3c41 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.CreateInquiryRequest +import com.weeth.domain.user.domain.port.InquiryNotifyPort +import com.weeth.domain.user.domain.port.InquirySavePort +import org.springframework.stereotype.Service + +@Service +class CreateInquiryUseCase( + private val inquirySavePort: InquirySavePort, + private val inquiryNotifyPort: InquiryNotifyPort, +) { + fun execute(request: CreateInquiryRequest) { + inquirySavePort.save(request.email, request.message) + inquiryNotifyPort.notify(request.email, request.message) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt new file mode 100644 index 00000000..deddd8e3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt @@ -0,0 +1,128 @@ +package com.weeth.domain.user.application.usecase.command + +import com.fasterxml.jackson.databind.ObjectMapper +import com.weeth.domain.user.application.dto.request.SocialLoginRequest +import com.weeth.domain.user.application.dto.response.SocialLoginResponse +import com.weeth.domain.user.application.exception.EmailNotFoundException +import com.weeth.domain.user.application.exception.UserInActiveException +import com.weeth.domain.user.application.mapper.UserMapper +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.UserSocialAccount +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.repository.UserSocialAccountRepository +import com.weeth.domain.user.domain.vo.SocialAuthResult +import com.weeth.domain.user.infrastructure.SocialAuthPortRegistry +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.jwt.domain.enums.TokenType +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SocialLoginUseCase( + private val userRepository: UserRepository, + private val userSocialAccountRepository: UserSocialAccountRepository, + private val socialAuthPortRegistry: SocialAuthPortRegistry, + private val jwtManageUseCase: JwtManageUseCase, + private val userMapper: UserMapper, + private val objectMapper: ObjectMapper, +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Transactional + fun socialLoginByKakao(request: SocialLoginRequest): SocialLoginResponse = + socialLogin(SocialProvider.KAKAO, request) + + @Transactional + fun socialLoginByApple(request: SocialLoginRequest): SocialLoginResponse = + socialLogin(SocialProvider.APPLE, request) + + /** + * Apple form_post 콜백 전용 로그인. + * id_token을 직접 검증하여 code 교환 과정을 생략하고, + * Apple이 최초 인가 시에만 전달하는 user JSON의 이름을 반영한다. + * + * TODO: 탈퇴 기능 구현 시 Apple 계정 연결 해제(revoke)를 위해 + * 콜백의 code를 Apple 토큰 엔드포인트에 교환하여 refresh token을 받고 DB에 저장해야 한다. + * (Apple Revoke Tokens API: POST https://appleid.apple.com/auth/revoke) + */ + @Transactional + fun socialLoginByAppleCallback( + idToken: String, + userJson: String?, + ): SocialLoginResponse { + val authResult = socialAuthPortRegistry.get(SocialProvider.APPLE).authenticateWithIdToken(idToken) + val userName = parseAppleUserName(userJson) + val effectiveResult = + if (!userName.isNullOrBlank() && authResult.name.isNullOrBlank()) { + authResult.copy(name = userName) + } else { + authResult + } + return processLogin(effectiveResult) + } + + private fun socialLogin( + provider: SocialProvider, + request: SocialLoginRequest, + ): SocialLoginResponse = processLogin(socialAuthPortRegistry.get(provider).authenticate(request.authCode)) + + private fun processLogin(authResult: SocialAuthResult): SocialLoginResponse { + val user = findOrCreateUser(authResult) + + if (user.isBannedOrLeft()) throw UserInActiveException() + + val tokenType = if (user.isRegistered()) TokenType.ACCESS else TokenType.TEMPORARY + val token = jwtManageUseCase.create(user.id, user.emailValue, tokenType) + + return userMapper.toSocialLoginResponse(user.name, token, user.isRegistered()) + } + + // TODO: 실제 서비스 출시 시 이메일 기반 기존 사용자 연동 및 유저 알림 기능 필요 + private fun findOrCreateUser(authResult: SocialAuthResult): User { + val existing = + userSocialAccountRepository + .findByProviderAndProviderUserId(authResult.provider, authResult.providerUserId) + .orElse(null) + + if (existing != null) return existing.user + + val email = + authResult.email.takeIf { authResult.emailVerified && it.isNotBlank() } ?: throw EmailNotFoundException() + + val user = + userRepository.save( + User.create( + name = authResult.name?.takeIf { it.isNotBlank() } ?: email.substringBefore("@"), + email = email, + status = Status.WAITING, // 소셜 로그인으로 회원가입 한 경우 WAITING으로 초기화 -> 동의 완료시 ACTIVE + ), + ) + + userSocialAccountRepository.save( + UserSocialAccount( + provider = authResult.provider, + providerUserId = authResult.providerUserId, + user = user, + ), + ) + + return user + } + + private fun parseAppleUserName(userJson: String?): String? { + if (userJson.isNullOrBlank()) return null + return try { + val node = objectMapper.readTree(userJson) + val nameNode = node["name"] ?: return null + val firstName = nameNode["firstName"]?.asText()?.trim() ?: "" + val lastName = nameNode["lastName"]?.asText()?.trim() ?: "" + "$lastName$firstName".trim().takeIf { it.isNotBlank() } + } catch (e: Exception) { + log.warn("Apple user JSON 파싱 실패: {}", e.message) + null + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt new file mode 100644 index 00000000..8069d93d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt @@ -0,0 +1,67 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest +import com.weeth.domain.user.application.exception.ProfileRequiredFieldsMissingException +import com.weeth.domain.user.application.exception.StudentIdExistsException +import com.weeth.domain.user.application.exception.TelExistsException +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.vo.Email +import com.weeth.global.common.vo.PhoneNumber +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UpdateUserProfileUseCase( + private val userRepository: UserRepository, +) { + @Transactional + fun updateProfile( + request: UpdateUserProfileRequest, + userId: Long, + ) { + val user = userRepository.getById(userId) + if (!user.isProfileCompleted()) { + validateRequiredFields(request) + } + validateDuplicate(request, userId, user) + user.update( + name = request.name, + email = request.email?.let { Email.from(it) }, + studentId = request.studentId, + tel = request.tel?.let { PhoneNumber.from(it) }, + school = request.school, + department = request.department, + ) + } + + private fun validateRequiredFields(request: UpdateUserProfileRequest) { + if (request.name == null || + request.email == null || + request.studentId == null || + request.tel == null || + request.school == null || + request.department == null + ) { + throw ProfileRequiredFieldsMissingException() + } + } + + private fun validateDuplicate( + request: UpdateUserProfileRequest, + userId: Long, + user: User, + ) { + val school = request.school ?: user.school + val studentId = request.studentId ?: user.studentId + if (school != null && studentId != null && + userRepository.existsBySchoolAndStudentIdAndIdIsNot(school, studentId, userId) + ) { + throw StudentIdExistsException() + } + val tel = request.tel ?: user.telValue + if (tel != null && userRepository.existsByTelAndIdIsNotValue(tel, userId)) { + throw TelExistsException() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/converter/EmailConverter.kt b/src/main/kotlin/com/weeth/domain/user/domain/converter/EmailConverter.kt new file mode 100644 index 00000000..836d8745 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/converter/EmailConverter.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.user.domain.converter + +import com.weeth.domain.user.domain.vo.Email +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter(autoApply = false) +class EmailConverter : AttributeConverter { + override fun convertToDatabaseColumn(attribute: Email?): String = attribute?.value ?: "" + + override fun convertToEntityAttribute(dbData: String?): Email = Email.from(dbData ?: "") +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt new file mode 100644 index 00000000..8bec40f2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -0,0 +1,168 @@ +package com.weeth.domain.user.domain.entity + +import com.weeth.domain.user.domain.converter.EmailConverter +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.vo.Email +import com.weeth.global.common.converter.PhoneNumberConverter +import com.weeth.global.common.entity.BaseEntity +import com.weeth.global.common.vo.PhoneNumber +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "users") +class User( + name: String, + email: Email, + studentId: String? = null, + tel: PhoneNumber? = null, + school: String? = null, + department: String? = null, + status: Status = Status.WAITING, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + var id: Long = 0L + private set + + @Column(nullable = false, length = 50) + var name: String = name.trim().also { require(it.isNotBlank()) { "이름은 공백일 수 없습니다." } } + private set + + @Convert(converter = EmailConverter::class) + @Column(name = "email", nullable = false, length = 255) + var email: Email = email + private set + + @Column(nullable = true, length = 20) + var studentId: String? = studentId + private set + + @Convert(converter = PhoneNumberConverter::class) + @Column(name = "tel", nullable = true, length = 20) + var tel: PhoneNumber? = tel + private set + + @Column(nullable = true, length = 50) + var school: String? = school + private set + + @Column(nullable = true, length = 100) + var department: String? = department + private set + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var status: Status = status + private set + + @Column(nullable = false) + var termsAgreed: Boolean = false + private set + + @Column(nullable = false) + var privacyAgreed: Boolean = false + private set + + val emailValue: String + get() = email.value + + val telValue: String? + get() = tel?.value + + fun leave() { + status = Status.LEFT + } + + fun isActive(): Boolean = status == Status.ACTIVE + + fun isInactive(): Boolean = !isActive() + + fun isBannedOrLeft(): Boolean = status == Status.BANNED || status == Status.LEFT + + fun isRegistered(): Boolean = status == Status.ACTIVE && termsAgreed && privacyAgreed + + fun isProfileCompleted(): Boolean = missingProfileFields().isEmpty() + + fun missingProfileFields(): List = + buildList { + if (studentId.isNullOrBlank()) add("studentId") + if (telValue.isNullOrBlank()) add("tel") + if (school.isNullOrBlank()) add("school") + if (department.isNullOrBlank()) add("department") + } + + fun update( + name: String? = null, + email: Email? = null, + studentId: String? = null, + tel: PhoneNumber? = null, + school: String? = null, + department: String? = null, + ) { + name?.let { + require(it.isNotBlank()) { "이름은 공백일 수 없습니다." } + this.name = it.trim() + } + email?.let { this.email = it } + studentId?.let { + require(it.isNotBlank()) { "학번은 공백일 수 없습니다." } + this.studentId = it + } + tel?.let { this.tel = it } + school?.let { + require(it.isNotBlank()) { "학교는 공백일 수 없습니다." } + this.school = it + } + department?.let { + require(it.isNotBlank()) { "학과는 공백일 수 없습니다." } + this.department = it + } + } + + fun agreeTerms( + termsAgreed: Boolean, + privacyAgreed: Boolean, + ) { + require(termsAgreed && privacyAgreed) { "모든 약관에 동의해야 합니다." } + this.termsAgreed = true + this.privacyAgreed = true + } + + fun accept() { + status = Status.ACTIVE + } + + fun ban() { + status = Status.BANNED + } + + companion object { + fun create( + name: String, + email: String, + studentId: String? = null, + tel: String? = null, + school: String? = null, + department: String? = null, + status: Status = Status.WAITING, + ): User = + User( + name = name, + email = Email.from(email), + studentId = studentId, + tel = tel?.let { PhoneNumber.from(it) }, + school = school, + department = department, + status = status, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt new file mode 100644 index 00000000..3ae8d03b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt @@ -0,0 +1,42 @@ +package com.weeth.domain.user.domain.entity + +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "user_social_account", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_provider_provider_user_id", + columnNames = ["provider", "provider_user_id"], + ), + ], +) +class UserSocialAccount( + @Enumerated(EnumType.STRING) + @Column(nullable = false) + val provider: SocialProvider, + @Column(name = "provider_user_id", nullable = false) + val providerUserId: String, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + val user: User, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_social_account_id") + val id: Long = 0L +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/enums/SocialProvider.kt b/src/main/kotlin/com/weeth/domain/user/domain/enums/SocialProvider.kt new file mode 100644 index 00000000..41dfc547 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/enums/SocialProvider.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.user.domain.enums + +enum class SocialProvider { + KAKAO, + APPLE, +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/enums/Status.kt b/src/main/kotlin/com/weeth/domain/user/domain/enums/Status.kt new file mode 100644 index 00000000..3c28b53d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/enums/Status.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.user.domain.enums + +enum class Status { + WAITING, + ACTIVE, + BANNED, + LEFT, +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/enums/StatusPriority.kt b/src/main/kotlin/com/weeth/domain/user/domain/enums/StatusPriority.kt new file mode 100644 index 00000000..3fb1dde3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/enums/StatusPriority.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.user.domain.enums + +import com.weeth.domain.user.application.exception.StatusNotFoundException + +enum class StatusPriority( + val priority: Int, +) { + ACTIVE(1), + WAITING(2), + LEFT(3), + BANNED(4), + ; + + companion object { + @JvmStatic + fun from(status: Status?): StatusPriority { + if (status == null) { + throw StatusNotFoundException() + } + return valueOf(status.name) + } + + @JvmStatic + fun fromStatus(status: Status?): StatusPriority = from(status) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/enums/UsersOrderBy.kt b/src/main/kotlin/com/weeth/domain/user/domain/enums/UsersOrderBy.kt new file mode 100644 index 00000000..a3d13a29 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/enums/UsersOrderBy.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.user.domain.enums + +enum class UsersOrderBy { + NAME_ASCENDING, + CARDINAL_DESCENDING, +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt new file mode 100644 index 00000000..392d64ee --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.user.domain.port + +interface InquiryNotifyPort { + fun notify( + email: String, + message: String?, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt new file mode 100644 index 00000000..f1512b2a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.user.domain.port + +interface InquirySavePort { + fun save( + email: String, + message: String?, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt new file mode 100644 index 00000000..9e0cf052 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.user.domain.port + +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.vo.SocialAuthResult + +interface SocialAuthPort { + fun provider(): SocialProvider + + fun authenticate(authCode: String): SocialAuthResult + + fun authenticateWithIdToken(idToken: String): SocialAuthResult = + throw UnsupportedOperationException("${provider()}은(는) ID token 직접 인증을 지원하지 않습니다") +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt new file mode 100644 index 00000000..057ea739 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.user.domain.repository + +import com.weeth.domain.user.domain.entity.User + +interface UserReader { + fun getById(userId: Long): User + + fun getByIdWithLock(userId: Long): User + + fun getByEmail(email: String): User + + fun findByIdOrNull(userId: Long): User? + + fun findAllByIds(userIds: List): List +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt new file mode 100644 index 00000000..fe85e91a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt @@ -0,0 +1,67 @@ +package com.weeth.domain.user.domain.repository + +import com.weeth.domain.user.application.exception.UserNotFoundException +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.vo.Email +import com.weeth.global.common.vo.PhoneNumber +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +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.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param +import java.util.Optional + +interface UserRepository : + JpaRepository, + UserReader { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT u FROM User u WHERE u.id = :id") + fun findByIdWithLock( + @Param("id") id: Long, + ): Optional + + fun findByEmail(email: Email): Optional + + fun existsByEmail(email: Email): Boolean + + fun existsByStudentId(studentId: String): Boolean + + fun existsByTel(tel: PhoneNumber): Boolean + + fun existsBySchoolAndStudentIdAndIdIsNot( + school: String, + studentId: String, + id: Long, + ): Boolean + + fun existsByTelAndIdIsNot( + tel: PhoneNumber, + id: Long, + ): Boolean + + fun findAllByOrderByNameAsc(): List + + fun findByEmailValue(email: String): Optional = findByEmail(Email.from(email)) + + fun existsByEmailValue(email: String): Boolean = existsByEmail(Email.from(email)) + + fun existsByTelValue(tel: String): Boolean = existsByTel(PhoneNumber.from(tel)) + + fun existsByTelAndIdIsNotValue( + tel: String, + id: Long, + ): Boolean = existsByTelAndIdIsNot(PhoneNumber.from(tel), id) + + override fun getById(userId: Long): User = findById(userId).orElseThrow { UserNotFoundException() } + + override fun getByIdWithLock(userId: Long): User = findByIdWithLock(userId).orElseThrow { UserNotFoundException() } + + override fun getByEmail(email: String): User = findByEmailValue(email).orElseThrow { UserNotFoundException() } + + override fun findByIdOrNull(userId: Long): User? = findById(userId).orElse(null) + + override fun findAllByIds(userIds: List): List = findAllById(userIds) +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserSocialAccountRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserSocialAccountRepository.kt new file mode 100644 index 00000000..05bac27e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserSocialAccountRepository.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.user.domain.repository + +import com.weeth.domain.user.domain.entity.UserSocialAccount +import com.weeth.domain.user.domain.enums.SocialProvider +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional + +interface UserSocialAccountRepository : JpaRepository { + fun findByProviderAndProviderUserId( + provider: SocialProvider, + providerUserId: String, + ): Optional +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/vo/Email.kt b/src/main/kotlin/com/weeth/domain/user/domain/vo/Email.kt new file mode 100644 index 00000000..80f5752a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/vo/Email.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.user.domain.vo + +data class Email private constructor( + val value: String, +) { + companion object { + fun from(raw: String): Email { + val normalized = raw.trim().lowercase() + require(normalized.isNotBlank()) { "이메일은 공백일 수 없습니다." } + require(EMAIL_REGEX.matches(normalized)) { "Invalid email format." } + return Email(normalized) + } + + private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/vo/SocialAuthResult.kt b/src/main/kotlin/com/weeth/domain/user/domain/vo/SocialAuthResult.kt new file mode 100644 index 00000000..99de1e8e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/vo/SocialAuthResult.kt @@ -0,0 +1,11 @@ +package com.weeth.domain.user.domain.vo + +import com.weeth.domain.user.domain.enums.SocialProvider + +data class SocialAuthResult( + val provider: SocialProvider, + val providerUserId: String, + val email: String, + val emailVerified: Boolean, + val name: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt new file mode 100644 index 00000000..318bbfde --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt @@ -0,0 +1,35 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.port.SocialAuthPort +import com.weeth.domain.user.domain.vo.SocialAuthResult +import com.weeth.global.auth.apple.AppleAuthService +import com.weeth.global.auth.apple.dto.AppleUserInfo +import org.springframework.stereotype.Component + +@Component +class AppleSocialAuthAdapter( + private val appleAuthService: AppleAuthService, +) : SocialAuthPort { + override fun provider(): SocialProvider = SocialProvider.APPLE + + override fun authenticate(authCode: String): SocialAuthResult { + val appleToken = appleAuthService.getAppleToken(authCode) + val userInfo = appleAuthService.verifyAndDecodeIdToken(appleToken.idToken) + return toSocialAuthResult(userInfo) + } + + override fun authenticateWithIdToken(idToken: String): SocialAuthResult { + val userInfo = appleAuthService.verifyAndDecodeIdToken(idToken) + return toSocialAuthResult(userInfo) + } + + private fun toSocialAuthResult(userInfo: AppleUserInfo): SocialAuthResult = + SocialAuthResult( + provider = SocialProvider.APPLE, + providerUserId = userInfo.appleId, + email = userInfo.email?.trim()?.lowercase() ?: "", + emailVerified = userInfo.emailVerified, + name = userInfo.name?.trim()?.takeIf { it.isNotBlank() }, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/KakaoSocialAuthAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/KakaoSocialAuthAdapter.kt new file mode 100644 index 00000000..17a23f15 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/KakaoSocialAuthAdapter.kt @@ -0,0 +1,39 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.application.exception.EmailNotFoundException +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.port.SocialAuthPort +import com.weeth.domain.user.domain.vo.SocialAuthResult +import com.weeth.global.auth.kakao.KakaoAuthService +import org.springframework.stereotype.Component + +@Component +class KakaoSocialAuthAdapter( + private val kakaoAuthService: KakaoAuthService, +) : SocialAuthPort { + override fun provider(): SocialProvider = SocialProvider.KAKAO + + override fun authenticate(authCode: String): SocialAuthResult { + val kakaoToken = kakaoAuthService.getKakaoToken(authCode) + val userInfo = kakaoAuthService.getUserInfo(kakaoToken.accessToken) + val account = userInfo.kakaoAccount + val email = account.email?.trim()?.lowercase() + val providerName = + account.profile + ?.nickname + ?.trim() + ?.takeIf { it.isNotBlank() } + + if (!account.isEmailValid || !account.isEmailVerified || email.isNullOrBlank()) { + throw EmailNotFoundException() + } + + return SocialAuthResult( + provider = SocialProvider.KAKAO, + providerUserId = userInfo.id.toString(), + email = email, + emailVerified = account.isEmailVerified, + name = providerName, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt new file mode 100644 index 00000000..8d2fc5c7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt @@ -0,0 +1,61 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.domain.port.InquirySavePort +import com.weeth.global.config.properties.NotionProperties +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import java.time.LocalDate + +@Component +class NotionInquirySaveAdapter( + private val notionProperties: NotionProperties, + restClientBuilder: RestClient.Builder, +) : InquirySavePort { + private val restClient = restClientBuilder.baseUrl("https://api.notion.com").build() + private val log = LoggerFactory.getLogger(javaClass) + + @Async + override fun save( + email: String, + message: String?, + ) { + val body = + mapOf( + "parent" to + mapOf( + "type" to "database_id", + "database_id" to notionProperties.inquiryDatabaseId, + ), + "properties" to + mapOf( + "문의내용" to + mapOf( + "title" to listOf(mapOf("text" to mapOf("content" to (message ?: "")))), + ), + "이메일" to + mapOf( + "email" to email, + ), + "날짜" to + mapOf( + "date" to mapOf("start" to LocalDate.now().toString()), + ), + ), + ) + + runCatching { + restClient + .post() + .uri("/v1/pages") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${notionProperties.token}") + .header("Notion-Version", notionProperties.version) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .body(body) + .retrieve() + .toBodilessEntity() + }.onFailure { e -> log.warn("Notion 저장 실패: {}", e.message) } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt new file mode 100644 index 00000000..60e276d7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt @@ -0,0 +1,35 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.domain.port.InquiryNotifyPort +import com.weeth.global.config.properties.SlackProperties +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient + +@Component +class SlackInquiryNotifyAdapter( + private val slackProperties: SlackProperties, + restClientBuilder: RestClient.Builder, +) : InquiryNotifyPort { + private val restClient = restClientBuilder.build() + private val log = LoggerFactory.getLogger(javaClass) + + @Async + override fun notify( + email: String, + message: String?, + ) { + val text = "*[랜딩 문의하기]*\n*이메일:* $email\n*문의 내용:*\n```${message ?: ""}```" + + runCatching { + restClient + .post() + .uri(slackProperties.webhookUrl) + .header("Content-Type", "application/json") + .body(mapOf("text" to text)) + .retrieve() + .toBodilessEntity() + }.onFailure { e -> log.warn("Slack 알림 전송 실패: {}", e.message) } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/SocialAuthPortRegistry.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/SocialAuthPortRegistry.kt new file mode 100644 index 00000000..791002d6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/SocialAuthPortRegistry.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.port.SocialAuthPort +import org.springframework.stereotype.Component + +@Component +class SocialAuthPortRegistry( + ports: List, +) { + private val portsByProvider = ports.associateBy { it.provider() } + + fun get(provider: SocialProvider): SocialAuthPort = + requireNotNull(portsByProvider[provider]) { "소셜 로그인 제공자를 찾을 수 없습니다: $provider" } +} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt new file mode 100644 index 00000000..4f80505c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt @@ -0,0 +1,84 @@ +package com.weeth.domain.user.presentation + +import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase +import com.weeth.global.auth.jwt.application.service.TokenCookieProvider +import com.weeth.global.config.properties.OAuthProperties +import io.swagger.v3.oas.annotations.Hidden +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.util.UriComponentsBuilder + +/** + * Apple Sign in with Apple의 form_post 콜백을 처리하는 컨트롤러. + */ +@Hidden +@RestController +class SocialCallbackController( + private val socialLoginUseCase: SocialLoginUseCase, + private val tokenCookieProvider: TokenCookieProvider, + oAuthProperties: OAuthProperties, +) { + private val log = LoggerFactory.getLogger(javaClass) + private val frontendRedirectUri = oAuthProperties.apple.frontendRedirectUri + + @PostMapping( + "/api/v4/users/social/apple/callback", + consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + fun handleCallback( + @RequestParam("id_token", required = false) idToken: String?, + @RequestParam("user", required = false) userJson: String?, + @RequestParam("error", required = false) error: String?, + ): ResponseEntity { + if (error != null || idToken.isNullOrBlank()) { + return redirect( + UriComponentsBuilder + .fromUriString(frontendRedirectUri) + .queryParam("error", error ?: "unknown") + .toUriString(), + ) + } + + return try { + val response = socialLoginUseCase.socialLoginByAppleCallback(idToken, userJson) + + val redirectUri = + UriComponentsBuilder + .fromUriString(frontendRedirectUri) + .queryParam("registered", response.registered) + .queryParam("name", response.name) + .toUriString() + + ResponseEntity + .status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, redirectUri) + .header( + HttpHeaders.SET_COOKIE, + tokenCookieProvider.createAccessTokenCookie(response.accessToken).toString(), + ).header( + HttpHeaders.SET_COOKIE, + tokenCookieProvider.createRefreshTokenCookie(response.refreshToken).toString(), + ).build() + } catch (e: Exception) { + log.error("Apple 콜백 처리 중 오류 발생", e) + redirect( + UriComponentsBuilder + .fromUriString(frontendRedirectUri) + .queryParam("error", "login_failed") + .toUriString(), + ) + } + } + + private fun redirect(uri: String): ResponseEntity = + ResponseEntity + .status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, uri) + .build() +} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt new file mode 100644 index 00000000..94f56fe6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -0,0 +1,130 @@ +package com.weeth.domain.user.presentation + +import com.weeth.domain.user.application.dto.request.AgreeTermsRequest +import com.weeth.domain.user.application.dto.request.CreateInquiryRequest +import com.weeth.domain.user.application.dto.request.SocialLoginRequest +import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest +import com.weeth.domain.user.application.dto.response.SocialLoginResponse +import com.weeth.domain.user.application.exception.UserErrorCode +import com.weeth.domain.user.application.usecase.command.AgreeTermsUseCase +import com.weeth.domain.user.application.usecase.command.AuthUserUseCase +import com.weeth.domain.user.application.usecase.command.CreateInquiryUseCase +import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase +import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.auth.jwt.application.service.TokenCookieProvider +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.security.SecurityRequirements +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.Valid +import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "USER", description = "사용자 API") +@RestController +@RequestMapping("/api/v4/users") +@ApiErrorCodeExample(UserErrorCode::class, JwtErrorCode::class) +class UserController( + private val authUserUseCase: AuthUserUseCase, + private val socialLoginUseCase: SocialLoginUseCase, + private val updateUserProfileUseCase: UpdateUserProfileUseCase, + private val agreeTermsUseCase: AgreeTermsUseCase, + private val createInquiryUseCase: CreateInquiryUseCase, + private val tokenCookieProvider: TokenCookieProvider, +) { + @PostMapping("/social/kakao") + @Operation(summary = "카카오 소셜 로그인(auth code flow)") + @SecurityRequirements + fun socialLoginByKakao( + @RequestBody @Valid request: SocialLoginRequest, + ): ResponseEntity> { + val response = socialLoginUseCase.socialLoginByKakao(request) + return buildTokenResponse( + CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, response), + response.accessToken, + response.refreshToken, + ) + } + + @PostMapping("/social/apple") + @Operation(summary = "애플 소셜 로그인(auth code flow)") + @SecurityRequirements + fun socialLoginByApple( + @RequestBody @Valid request: SocialLoginRequest, + ): ResponseEntity> { + val response = socialLoginUseCase.socialLoginByApple(request) + return buildTokenResponse( + CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, response), + response.accessToken, + response.refreshToken, + ) + } + + @PostMapping("/social/refresh") + @Operation(summary = "토큰 재발급", description = "쿠키를 사용해 토큰을 재발급합니다.") + @SecurityRequirements + fun refreshToken(request: HttpServletRequest): ResponseEntity> { + val jwtDto = authUserUseCase.refreshToken(request) + return buildTokenResponse( + CommonResponse.success(UserResponseCode.JWT_REFRESH_SUCCESS, jwtDto), + jwtDto.accessToken, + jwtDto.refreshToken, + ) + } + + @PostMapping("/terms") + @Operation(summary = "약관 동의") + fun agreeTerms( + @RequestBody @Valid request: AgreeTermsRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): ResponseEntity> { + val jwtDto = agreeTermsUseCase.execute(userId, request) + return buildTokenResponse( + CommonResponse.success(UserResponseCode.USER_TERMS_AGREE_SUCCESS, jwtDto), + jwtDto.accessToken, + jwtDto.refreshToken, + ) + } + + @PatchMapping + @Operation(summary = "내 정보 수정") + fun update( + @RequestBody @Valid request: UpdateUserProfileRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + updateUserProfileUseCase.updateProfile(request, userId) + return CommonResponse.success(UserResponseCode.USER_UPDATE_SUCCESS) + } + + @PostMapping("/inquiries") + @Operation(summary = "문의하기") + @SecurityRequirements + fun createInquiry( + @RequestBody @Valid request: CreateInquiryRequest, + ): CommonResponse { + createInquiryUseCase.execute(request) + return CommonResponse.success(UserResponseCode.INQUIRY_SEND_SUCCESS) + } + + private fun buildTokenResponse( + body: CommonResponse, + accessToken: String, + refreshToken: String, + ): ResponseEntity> = + ResponseEntity + .ok() + .header(HttpHeaders.SET_COOKIE, tokenCookieProvider.createAccessTokenCookie(accessToken).toString()) + .header(HttpHeaders.SET_COOKIE, tokenCookieProvider.createRefreshTokenCookie(refreshToken).toString()) + .body(body) +} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt new file mode 100644 index 00000000..c1776a65 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.user.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class UserResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + USER_UPDATE_SUCCESS(10901, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), + JWT_REFRESH_SUCCESS(10902, HttpStatus.OK, "토큰 재발급에 성공했습니다."), + SOCIAL_LOGIN_SUCCESS(10903, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), + USER_TERMS_AGREE_SUCCESS(10904, HttpStatus.OK, "약관 동의가 성공적으로 처리되었습니다."), + INQUIRY_SEND_SUCCESS(10905, HttpStatus.OK, "문의가 성공적으로 접수되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUser.kt b/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUser.kt new file mode 100644 index 00000000..71d3cce6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUser.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.annotation + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class CurrentUser diff --git a/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt new file mode 100644 index 00000000..271197e4 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt @@ -0,0 +1,266 @@ +package com.weeth.global.auth.apple + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.weeth.global.auth.apple.dto.ApplePublicKey +import com.weeth.global.auth.apple.dto.ApplePublicKeys +import com.weeth.global.auth.apple.dto.AppleTokenResponse +import com.weeth.global.auth.apple.dto.AppleUserInfo +import com.weeth.global.auth.apple.exception.AppleAuthenticationException +import com.weeth.global.config.properties.OAuthProperties +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import org.slf4j.LoggerFactory +import org.springframework.core.io.ClassPathResource +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestClient +import org.springframework.web.client.body +import java.io.FileInputStream +import java.io.IOException +import java.io.InputStream +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.PublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAPublicKeySpec +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.Base64 +import java.util.Date + +@Service +class AppleAuthService( + oAuthProperties: OAuthProperties, + restClientBuilder: RestClient.Builder, + private val objectMapper: ObjectMapper, + private val clock: Clock = Clock.systemUTC(), +) { + private data class CachedKeys( + val keys: ApplePublicKeys, + val expiresAt: Instant, + ) + + private val log = LoggerFactory.getLogger(javaClass) + + private val appleProperties = oAuthProperties.apple + private val restClient = restClientBuilder.build() + private val publicKeysTtl: Duration = Duration.ofHours(1) + + @Volatile private var cached: CachedKeys? = null + private val privateKey: PrivateKey by lazy { loadPrivateKey() } + + fun getAppleToken(authCode: String): AppleTokenResponse { + val clientSecret = generateClientSecret() + + val body = + LinkedMultiValueMap().apply { + add("grant_type", "authorization_code") + add("client_id", appleProperties.clientId) + add("client_secret", clientSecret) + add("code", authCode) + add("redirect_uri", appleProperties.redirectUri) + } + + return requireNotNull( + restClient + .post() + .uri(appleProperties.tokenUri) + .body(body) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .body(), + ) + } + + fun verifyAndDecodeIdToken(idToken: String): AppleUserInfo { + try { + val tokenParts = idToken.split(".") + if (tokenParts.size < 2) { + throw AppleAuthenticationException() + } + val header = decodeBase64Url(tokenParts[0]) + val headerJson = parseJson(header) + val kid = headerJson["kid"]?.asText()?.takeIf { it.isNotBlank() } ?: throw AppleAuthenticationException() + val alg = headerJson["alg"]?.asText() + if (alg != "RS256") { + throw AppleAuthenticationException() + } + + val publicKeys = getApplePublicKeys() + + val matchedKey = + publicKeys.keys + .firstOrNull { key -> key.kid == kid } + ?: throw AppleAuthenticationException() + + val publicKey = generatePublicKey(matchedKey) + val claims = + Jwts + .parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(idToken) + .payload + + validateClaims(claims) + + val appleId = claims.subject + val email = claims.get("email", String::class.java) + val emailVerified = parseEmailVerified(claims["email_verified"]) + val name = claims.get("name", String::class.java) + + return AppleUserInfo( + appleId = appleId, + email = email, + emailVerified = emailVerified, + name = name, + ) + } catch (e: AppleAuthenticationException) { + throw e + } catch (e: Exception) { + log.error("애플 ID Token 검증 실패", e) + throw AppleAuthenticationException() + } + } + + private fun generateClientSecret(): String { + try { + val now = Instant.now(clock) + val expiration = now.plus(Duration.ofDays(150)) // Apple limit is <= 6 months. + + return Jwts + .builder() + .header() + .keyId(appleProperties.keyId) + .and() + .issuer(appleProperties.teamId) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .audience() + .add("https://appleid.apple.com") + .and() + .subject(appleProperties.clientId) + .signWith(privateKey, Jwts.SIG.ES256) + .compact() + } catch (e: Exception) { + log.error("애플 Client Secret 생성 실패", e) + throw AppleAuthenticationException() + } + } + + private fun loadPrivateKey(): PrivateKey = + try { + getInputStream(appleProperties.privateKeyPath).use { inputStream -> + var privateKeyContent = String(inputStream.readAllBytes(), StandardCharsets.UTF_8) + privateKeyContent = + privateKeyContent + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") + + val keyBytes = Base64.getDecoder().decode(privateKeyContent) + val keyFactory = KeyFactory.getInstance("EC") + keyFactory.generatePrivate(PKCS8EncodedKeySpec(keyBytes)) + } + } catch (e: Exception) { + log.error("애플 개인키 로드 실패", e) + throw AppleAuthenticationException() + } + + @Throws(IOException::class) + private fun getInputStream(path: String): InputStream = + if (path.startsWith("/") || path.matches(Regex("^[A-Za-z]:.*"))) { + FileInputStream(path) + } else { + ClassPathResource(path).inputStream + } + + private fun generatePublicKey(applePublicKey: ApplePublicKey): PublicKey = + try { + val nBytes = Base64.getUrlDecoder().decode(applePublicKey.n) + val eBytes = Base64.getUrlDecoder().decode(applePublicKey.e) + + val n = BigInteger(1, nBytes) + val e = BigInteger(1, eBytes) + + val publicKeySpec = RSAPublicKeySpec(n, e) + val keyFactory = KeyFactory.getInstance("RSA") + + keyFactory.generatePublic(publicKeySpec) + } catch (ex: Exception) { + log.error("애플 공개키 생성 실패", ex) + throw AppleAuthenticationException() + } + + private fun validateClaims(claims: Claims) { + val iss = claims.issuer + val audiences = claims.audience + val expiration = claims.expiration + val now = Date.from(Instant.now(clock)) + + when { + iss != "https://appleid.apple.com" -> { + log.warn("유효하지 않은 발급자: {}", iss) + throw AppleAuthenticationException() + } + + audiences.isEmpty() || !audiences.contains(appleProperties.clientId) -> { + log.warn("유효하지 않은 audience: {}. 기대값: {}", audiences, appleProperties.clientId) + throw AppleAuthenticationException() + } + + expiration.before(now) -> { + log.warn("만료된 ID Token") + throw AppleAuthenticationException() + } + + claims.subject.isNullOrBlank() -> { + log.warn("유효하지 않은 subject") + throw AppleAuthenticationException() + } + } + } + + private fun getApplePublicKeys(): ApplePublicKeys { + val now = Instant.now(clock) + cached?.let { + if (now.isBefore(it.expiresAt)) { + return it.keys + } + } + + val fetched = + requireNotNull( + restClient + .get() + .uri(appleProperties.keysUri) + .retrieve() + .body(), + ) + + cached = CachedKeys(fetched, now.plus(publicKeysTtl)) + return fetched + } + + private fun parseJson(json: String): ObjectNode = + try { + objectMapper.readTree(json) as? ObjectNode ?: throw AppleAuthenticationException() + } catch (e: Exception) { + throw AppleAuthenticationException() + } + + private fun decodeBase64Url(value: String): String = + String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8) + + private fun parseEmailVerified(raw: Any?): Boolean = + when (raw) { + is Boolean -> raw + is String -> raw.toBooleanStrictOrNull() ?: false + else -> false + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKey.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKey.kt new file mode 100644 index 00000000..8d778923 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKey.kt @@ -0,0 +1,10 @@ +package com.weeth.global.auth.apple.dto + +data class ApplePublicKey( + val kty: String, + val kid: String, + val use: String, + val alg: String, + val n: String, + val e: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKeys.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKeys.kt new file mode 100644 index 00000000..82950ccf --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKeys.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.apple.dto + +data class ApplePublicKeys( + val keys: List, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleTokenResponse.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleTokenResponse.kt new file mode 100644 index 00000000..5cb7f8ee --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleTokenResponse.kt @@ -0,0 +1,16 @@ +package com.weeth.global.auth.apple.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class AppleTokenResponse( + @field:JsonProperty("access_token") + val accessToken: String, + @field:JsonProperty("token_type") + val tokenType: String, + @field:JsonProperty("expires_in") + val expiresIn: Long, + @field:JsonProperty("refresh_token") + val refreshToken: String, + @field:JsonProperty("id_token") + val idToken: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt new file mode 100644 index 00000000..444de610 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt @@ -0,0 +1,8 @@ +package com.weeth.global.auth.apple.dto + +data class AppleUserInfo( + val appleId: String, + val email: String?, + val emailVerified: Boolean, + val name: String? = null, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/exception/AppleAuthenticationException.kt b/src/main/kotlin/com/weeth/global/auth/apple/exception/AppleAuthenticationException.kt new file mode 100644 index 00000000..02ebf951 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/exception/AppleAuthenticationException.kt @@ -0,0 +1,6 @@ +package com.weeth.global.auth.apple.exception + +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.BaseException + +class AppleAuthenticationException : BaseException(JwtErrorCode.APPLE_AUTHENTICATION_FAILED) diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt new file mode 100644 index 00000000..c00619df --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt @@ -0,0 +1,79 @@ +package com.weeth.global.auth.authentication + +import com.fasterxml.jackson.databind.ObjectMapper +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.response.CommonResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component + +@Component +class CustomAccessDeniedHandler( + private val objectMapper: ObjectMapper, +) : AccessDeniedHandler { + private val errorLog = LoggerFactory.getLogger("ERROR_LOG") + + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException, + ) { + if (isTemporaryUser()) { + setRegistrationIncompleteResponse(response) + } else { + setForbiddenResponse(response) + } + + try { + MDC.put("status", response.status.toString()) + MDC.put("errorType", accessDeniedException::class.simpleName ?: "AccessDeniedException") + MDC.put("errorMessage", accessDeniedException.message ?: ErrorMessage.FORBIDDEN.message) + errorLog.warn("Access Denied") + } finally { + MDC.remove("status") + MDC.remove("errorType") + MDC.remove("errorMessage") + } + } + + private fun isTemporaryUser(): Boolean = + SecurityContextHolder + .getContext() + .authentication + ?.authorities + ?.any { it.authority == "ROLE_TEMPORARY" } + ?: false + + private fun setRegistrationIncompleteResponse(response: HttpServletResponse) { + val errorCode = JwtErrorCode.REGISTRATION_INCOMPLETE + response.status = errorCode.status.value() + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + + val body = + objectMapper.writeValueAsString( + CommonResponse.error(errorCode), + ) + response.writer.write(body) + } + + private fun setForbiddenResponse(response: HttpServletResponse) { + response.status = HttpServletResponse.SC_FORBIDDEN + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + + val body = + objectMapper.writeValueAsString( + CommonResponse.createFailure( + ErrorMessage.FORBIDDEN.code, + ErrorMessage.FORBIDDEN.message, + ), + ) + response.writer.write(body) + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt new file mode 100644 index 00000000..4b50d4ea --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt @@ -0,0 +1,51 @@ +package com.weeth.global.auth.authentication + +import com.fasterxml.jackson.databind.ObjectMapper +import com.weeth.global.common.response.CommonResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component + +@Component +class CustomAuthenticationEntryPoint( + private val objectMapper: ObjectMapper, +) : AuthenticationEntryPoint { + private val errorLog = LoggerFactory.getLogger("ERROR_LOG") + + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException, + ) { + setResponse(response) + try { + MDC.put("status", response.status.toString()) + MDC.put("errorType", authException::class.simpleName ?: "AuthenticationException") + MDC.put("errorMessage", authException.message ?: ErrorMessage.UNAUTHORIZED.message) + errorLog.warn("Authentication Failed") + } finally { + MDC.remove("status") + MDC.remove("errorType") + MDC.remove("errorMessage") + } + } + + private fun setResponse(response: HttpServletResponse) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + + val message = + objectMapper.writeValueAsString( + CommonResponse.createFailure( + ErrorMessage.UNAUTHORIZED.code, + ErrorMessage.UNAUTHORIZED.message, + ), + ) + response.writer.write(message) + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/ErrorMessage.kt b/src/main/kotlin/com/weeth/global/auth/authentication/ErrorMessage.kt new file mode 100644 index 00000000..2d458307 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/authentication/ErrorMessage.kt @@ -0,0 +1,9 @@ +package com.weeth.global.auth.authentication + +enum class ErrorMessage( + val code: Int, + val message: String, +) { + UNAUTHORIZED(401, "인증 정보가 존재하지 않습니다."), + FORBIDDEN(403, "권한이 없습니다."), +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/dto/JwtDto.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/dto/JwtDto.kt new file mode 100644 index 00000000..d72ba9ef --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/dto/JwtDto.kt @@ -0,0 +1,6 @@ +package com.weeth.global.auth.jwt.application.dto + +data class JwtDto( + val accessToken: String, + val refreshToken: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/AnonymousAuthenticationException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/AnonymousAuthenticationException.kt new file mode 100644 index 00000000..6e6e4960 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/AnonymousAuthenticationException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class AnonymousAuthenticationException : BaseException(JwtErrorCode.ANONYMOUS_AUTHENTICATION) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/InvalidTokenException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/InvalidTokenException.kt new file mode 100644 index 00000000..9571c89c --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/InvalidTokenException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class InvalidTokenException : BaseException(JwtErrorCode.INVALID_TOKEN) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt new file mode 100644 index 00000000..1ed765c7 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt @@ -0,0 +1,29 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class JwtErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("토큰의 구조가 올바르지 않거나(Malformed), 서명이 유효하지 않은 경우 발생합니다. 토큰을 재발급 받아주세요.") + INVALID_TOKEN(29000, HttpStatus.BAD_REQUEST, "올바르지 않은 Token 입니다."), + + @ExplainError("Redis에 해당 리프레시 토큰이 존재하지 않습니다. 토큰이 만료되었거나, 이미 로그아웃(삭제)된 상태일 수 있습니다. 다시 로그인해주세요.") + REDIS_TOKEN_NOT_FOUND(29001, HttpStatus.NOT_FOUND, "저장된 리프레시 토큰이 존재하지 않습니다."), + + @ExplainError("API 요청 헤더(Authorization)에 토큰 값이 포함되지 않았거나 비어있을 때 발생합니다.") + TOKEN_NOT_FOUND(29002, HttpStatus.NOT_FOUND, "헤더에서 토큰을 찾을 수 없습니다."), + + @ExplainError("인증이 필요한 리소스에 인증 정보 없이(Anonymous) 접근을 시도했을 때 발생합니다. (Spring Security 필터 단계 차단)") + ANONYMOUS_AUTHENTICATION(29003, HttpStatus.UNAUTHORIZED, "인증정보가 존재하지 않습니다."), + + @ExplainError("Apple 인증 과정에서 토큰 교환 또는 검증에 실패했을 때 발생합니다.") + APPLE_AUTHENTICATION_FAILED(29004, HttpStatus.UNAUTHORIZED, "애플 로그인에 실패했습니다."), + + @ExplainError("약관 동의가 완료되지 않은 사용자(TEMPORARY 토큰)가 서비스 API에 접근을 시도했을 때 발생합니다.") + REGISTRATION_INCOMPLETE(29005, HttpStatus.FORBIDDEN, "회원가입이 완료되지 않았습니다. 약관 동의를 진행해주세요."), +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/RedisTokenNotFoundException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/RedisTokenNotFoundException.kt new file mode 100644 index 00000000..54b8fde8 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/RedisTokenNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class RedisTokenNotFoundException : BaseException(JwtErrorCode.REDIS_TOKEN_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/TokenNotFoundException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/TokenNotFoundException.kt new file mode 100644 index 00000000..3a652367 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/TokenNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class TokenNotFoundException : BaseException(JwtErrorCode.TOKEN_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt new file mode 100644 index 00000000..9e1698a3 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt @@ -0,0 +1,82 @@ +package com.weeth.global.auth.jwt.application.service + +import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException +import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.config.properties.CookieProperties +import com.weeth.global.config.properties.JwtProperties +import io.jsonwebtoken.Claims +import jakarta.servlet.http.HttpServletRequest +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class JwtTokenExtractor( + private val jwtProperties: JwtProperties, + private val jwtTokenProvider: JwtTokenProvider, + private val cookieProperties: CookieProperties, +) { + private val log = LoggerFactory.getLogger(javaClass) + + data class TokenClaims( + val id: Long, + val email: String, + val tokenType: TokenType, + ) + + fun extractRefreshToken(request: HttpServletRequest): String = + extractRefreshTokenFromCookie(request) + ?: extractRefreshTokenFromHeader(request) + ?: throw TokenNotFoundException() + + private fun extractRefreshTokenFromCookie(request: HttpServletRequest): String? = + request.cookies + ?.firstOrNull { it.name == cookieProperties.refreshTokenName } + ?.value + ?.takeIf { it.isNotBlank() } + + private fun extractRefreshTokenFromHeader(request: HttpServletRequest): String? = + request + .getHeader(jwtProperties.refresh.header) + ?.takeIf { it.startsWith(BEARER) } + ?.removePrefix(BEARER) + + fun extractAccessToken(request: HttpServletRequest): String? = + request + .getHeader(jwtProperties.access.header) + ?.takeIf { it.startsWith(BEARER) } + ?.removePrefix(BEARER) + + fun extractEmail(accessToken: String): String? = + extractClaim(accessToken, JwtTokenProvider.EMAIL_CLAIM, String::class.java) + + fun extractId(token: String): Long? = extractClaim(token, JwtTokenProvider.ID_CLAIM, Long::class.javaObjectType) + + fun extractClaims(token: String): TokenClaims? = + runCatching { + val claims: Claims = jwtTokenProvider.parseClaims(token) + val tokenTypeStr = claims.get(JwtTokenProvider.TOKEN_TYPE_CLAIM, String::class.java) + TokenClaims( + id = claims.get(JwtTokenProvider.ID_CLAIM, Long::class.javaObjectType), + email = claims.get(JwtTokenProvider.EMAIL_CLAIM, String::class.java), + tokenType = tokenTypeStr?.let { TokenType.valueOf(it) } ?: TokenType.ACCESS, + ) + }.onFailure { + log.error("액세스 토큰이 유효하지 않습니다: {}", it.message) + }.getOrNull() + + private fun extractClaim( + token: String, + claimName: String, + type: Class, + ): T? = + runCatching { + jwtTokenProvider.parseClaims(token).get(claimName, type) + }.onFailure { + log.error("액세스 토큰 claim 추출 실패({}): {}", claimName, it.message) + }.getOrNull() + + companion object { + private const val BEARER = "Bearer " + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt new file mode 100644 index 00000000..20ed71a1 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt @@ -0,0 +1,48 @@ +package com.weeth.global.auth.jwt.application.service + +import com.weeth.global.config.properties.CookieProperties +import com.weeth.global.config.properties.JwtProperties +import org.springframework.http.ResponseCookie +import org.springframework.stereotype.Service +import java.time.Duration + +@Service +class TokenCookieProvider( + private val cookieProperties: CookieProperties, + private val jwtProperties: JwtProperties, +) { + fun createAccessTokenCookie(token: String): ResponseCookie = + buildCookie( + name = cookieProperties.accessTokenName, + value = token, + maxAge = Duration.ofMillis(jwtProperties.access.expiration), + path = cookieProperties.path, + ) + + fun createRefreshTokenCookie(token: String): ResponseCookie = + buildCookie( + name = cookieProperties.refreshTokenName, + value = token, + maxAge = Duration.ofMillis(jwtProperties.refresh.expiration), + path = cookieProperties.refreshPath, + ) + + private fun buildCookie( + name: String, + value: String, + maxAge: Duration, + path: String, + ): ResponseCookie = + ResponseCookie + .from(name, value) + .httpOnly(cookieProperties.httpOnly) + .secure(cookieProperties.secure) + .path(path) + .maxAge(maxAge) + .sameSite(cookieProperties.sameSite) + .apply { + if (cookieProperties.domain.isNotBlank()) { + domain(cookieProperties.domain) + } + }.build() +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt new file mode 100644 index 00000000..e8439ebb --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt @@ -0,0 +1,41 @@ +package com.weeth.global.auth.jwt.application.usecase + +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import org.springframework.stereotype.Service + +@Service +class JwtManageUseCase( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtTokenExtractor: JwtTokenExtractor, + private val refreshTokenStore: RefreshTokenStorePort, +) { + fun create( + userId: Long, + email: String, + tokenType: TokenType, + ): JwtDto { + val accessToken = jwtTokenProvider.createAccessToken(userId, email, tokenType) + val refreshToken = jwtTokenProvider.createRefreshToken(userId) + + refreshTokenStore.save(userId, refreshToken, email, tokenType) + + return JwtDto(accessToken, refreshToken) + } + + fun reIssueToken(requestToken: String): JwtDto { + jwtTokenProvider.validate(requestToken) + + val userId = jwtTokenExtractor.extractId(requestToken) ?: throw InvalidTokenException() + refreshTokenStore.validateRefreshToken(userId, requestToken) + + val email = refreshTokenStore.getEmail(userId) + val tokenType = refreshTokenStore.getTokenType(userId) + + return create(userId, email, tokenType) + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/enums/TokenType.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/enums/TokenType.kt new file mode 100644 index 00000000..a85d0db7 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/enums/TokenType.kt @@ -0,0 +1,6 @@ +package com.weeth.global.auth.jwt.domain.enums + +enum class TokenType { + TEMPORARY, // 약관 미동의 사용자용 (약관 동의 API만 접근 가능) + ACCESS, // 정상 사용자용 +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt new file mode 100644 index 00000000..db9466c6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt @@ -0,0 +1,23 @@ +package com.weeth.global.auth.jwt.domain.port + +import com.weeth.global.auth.jwt.domain.enums.TokenType + +interface RefreshTokenStorePort { + fun save( + userId: Long, + refreshToken: String, + email: String, + tokenType: TokenType, + ) + + fun delete(userId: Long) + + fun validateRefreshToken( + userId: Long, + requestToken: String, + ) + + fun getEmail(userId: Long): String + + fun getTokenType(userId: Long): TokenType +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt new file mode 100644 index 00000000..b5cad298 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt @@ -0,0 +1,90 @@ +package com.weeth.global.auth.jwt.domain.service + +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.config.properties.JwtProperties +import io.jsonwebtoken.Claims +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.JwtParser +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.nio.charset.StandardCharsets +import java.util.Date +import javax.crypto.SecretKey + +@Service +class JwtTokenProvider( + jwtProperties: JwtProperties, +) { + private val log = LoggerFactory.getLogger(javaClass) + + private val secretKey: SecretKey = Keys.hmacShaKeyFor(jwtProperties.key.toByteArray(StandardCharsets.UTF_8)) + private val accessTokenExpirationPeriod: Long = jwtProperties.access.expiration + private val refreshTokenExpirationPeriod: Long = jwtProperties.refresh.expiration + private val jwtParser: JwtParser = + Jwts + .parser() + .verifyWith(secretKey) + .build() + + fun createAccessToken( + id: Long, + email: String, + tokenType: TokenType, + ): String { + val now = Date() + return Jwts + .builder() + .subject(ACCESS_TOKEN_SUBJECT) + .claim(ID_CLAIM, id) + .claim(EMAIL_CLAIM, email) + .claim(TOKEN_TYPE_CLAIM, tokenType.name) + .issuedAt(now) + .expiration(Date(now.time + accessTokenExpirationPeriod)) + .signWith(secretKey) + .compact() + } + + fun createRefreshToken(id: Long): String { + val now = Date() + return Jwts + .builder() + .subject(REFRESH_TOKEN_SUBJECT) + .claim(ID_CLAIM, id) + .issuedAt(now) + .expiration(Date(now.time + refreshTokenExpirationPeriod)) + .signWith(secretKey) + .compact() + } + + fun validate(token: String) { + parseSignedClaims(token, "유효하지 않은 토큰입니다.") + } + + fun parseClaims(token: String): Claims = + parseSignedClaims(token, "토큰 파싱 실패") + .payload + + private fun parseSignedClaims( + token: String, + errorMessage: String, + ) = try { + jwtParser.parseSignedClaims(token) + } catch (e: JwtException) { + log.error("{}: {}", errorMessage, e.message) + throw InvalidTokenException() + } catch (e: IllegalArgumentException) { + log.error("{}: {}", errorMessage, e.message) + throw InvalidTokenException() + } + + companion object { + private const val ACCESS_TOKEN_SUBJECT = "AccessToken" + private const val REFRESH_TOKEN_SUBJECT = "RefreshToken" + internal const val EMAIL_CLAIM = "email" + internal const val ID_CLAIM = "id" + internal const val TOKEN_TYPE_CLAIM = "tokenType" + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt new file mode 100644 index 00000000..9cb494ce --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -0,0 +1,62 @@ +package com.weeth.global.auth.jwt.filter + +import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.auth.model.AuthenticatedUser +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter + +class JwtAuthenticationProcessingFilter( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtTokenExtractor: JwtTokenExtractor, +) : OncePerRequestFilter() { + private val log = LoggerFactory.getLogger(javaClass) + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + try { + val accessToken = jwtTokenExtractor.extractAccessToken(request) ?: throw TokenNotFoundException() + jwtTokenProvider.validate(accessToken) + saveAuthentication(accessToken) + } catch (e: TokenNotFoundException) { + log.debug("Token not found: {}", e.message) + } catch (e: RuntimeException) { + log.info("error token: {}", e.message) + } + + filterChain.doFilter(request, response) + } + + private fun saveAuthentication(accessToken: String) { + val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() + val principal = AuthenticatedUser(claims.id, claims.email) + + val role = + when (claims.tokenType) { + TokenType.TEMPORARY -> "ROLE_TEMPORARY" + TokenType.ACCESS -> "ROLE_USER" + } + + val authentication = + UsernamePasswordAuthenticationToken( + principal, + null, + listOf(SimpleGrantedAuthority(role)), + ) + + SecurityContextHolder.getContext().authentication = authentication + MDC.put("userId", claims.id.toString()) + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt new file mode 100644 index 00000000..9ccb9542 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt @@ -0,0 +1,77 @@ +package com.weeth.global.auth.jwt.infrastructure + +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException +import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort +import com.weeth.global.config.properties.JwtProperties +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class RedisRefreshTokenStoreAdapter( + private val jwtProperties: JwtProperties, + private val redisTemplate: RedisTemplate, +) : RefreshTokenStorePort { + override fun save( + userId: Long, + refreshToken: String, + email: String, + tokenType: TokenType, + ) { + val key = getKey(userId) + redisTemplate.opsForHash().putAll( + key, + mapOf( + TOKEN to refreshToken, + EMAIL to email, + TOKEN_TYPE to tokenType.name, + ), + ) + redisTemplate.expire(key, jwtProperties.refresh.expiration, TimeUnit.MINUTES) + } + + override fun delete(userId: Long) { + val key = getKey(userId) + redisTemplate.delete(key) + } + + override fun validateRefreshToken( + userId: Long, + requestToken: String, + ) { + if (find(userId) != requestToken) { + throw InvalidTokenException() + } + } + + override fun getEmail(userId: Long): String { + val key = getKey(userId) + return redisTemplate.opsForHash().get(key, EMAIL) + ?: throw RedisTokenNotFoundException() + } + + override fun getTokenType(userId: Long): TokenType { + val key = getKey(userId) + val value = + redisTemplate.opsForHash().get(key, TOKEN_TYPE) + ?: return TokenType.ACCESS // 기존 토큰 호환성을 위한 기본값 + return TokenType.valueOf(value) + } + + private fun find(userId: Long): String { + val key = getKey(userId) + return redisTemplate.opsForHash().get(key, TOKEN) + ?: throw RedisTokenNotFoundException() + } + + private fun getKey(userId: Long): String = "$PREFIX$userId" + + companion object { + private const val PREFIX = "refreshToken:" + private const val TOKEN = "token" + private const val EMAIL = "email" + private const val TOKEN_TYPE = "tokenType" + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt b/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt new file mode 100644 index 00000000..c97334e6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt @@ -0,0 +1,49 @@ +package com.weeth.global.auth.kakao + +import com.weeth.global.auth.kakao.dto.KakaoTokenResponse +import com.weeth.global.auth.kakao.dto.KakaoUserInfoResponse +import com.weeth.global.config.properties.OAuthProperties +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestClient +import org.springframework.web.client.body + +@Service +class KakaoAuthService( + oAuthProperties: OAuthProperties, + restClientBuilder: RestClient.Builder, +) { + private val kakaoProperties = oAuthProperties.kakao + private val restClient = restClientBuilder.build() + + fun getKakaoToken(authCode: String): KakaoTokenResponse { + val body = + LinkedMultiValueMap().apply { + add("grant_type", kakaoProperties.grantType) + add("client_id", kakaoProperties.clientId) + add("redirect_uri", kakaoProperties.redirectUri) + add("code", authCode) + } + + return requireNotNull( + restClient + .post() + .uri(kakaoProperties.tokenUri) + .body(body) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .body(), + ) + } + + fun getUserInfo(accessToken: String): KakaoUserInfoResponse = + requireNotNull( + restClient + .get() + .uri(kakaoProperties.userInfoUri) + .header("Authorization", "Bearer $accessToken") + .retrieve() + .body(), + ) +} diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccessToken.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccessToken.kt new file mode 100644 index 00000000..95fa14f5 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccessToken.kt @@ -0,0 +1,8 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoAccessToken( + @field:JsonProperty("access_token") + val accessToken: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt new file mode 100644 index 00000000..1faf46d2 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt @@ -0,0 +1,14 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoAccount( + @field:JsonProperty("is_email_valid") + val isEmailValid: Boolean, + @field:JsonProperty("is_email_verified") + val isEmailVerified: Boolean, + @field:JsonProperty("email") + val email: String?, + @field:JsonProperty("profile") + val profile: KakaoProfile? = null, +) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt new file mode 100644 index 00000000..e7ce2ef3 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt @@ -0,0 +1,8 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoProfile( + @field:JsonProperty("nickname") + val nickname: String?, +) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.kt new file mode 100644 index 00000000..f188c14b --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.kt @@ -0,0 +1,16 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoTokenResponse( + @field:JsonProperty("token_type") + val tokenType: String, + @field:JsonProperty("access_token") + val accessToken: String, + @field:JsonProperty("expires_in") + val expiresIn: Int, + @field:JsonProperty("refresh_token") + val refreshToken: String, + @field:JsonProperty("refresh_token_expires_in") + val refreshTokenExpiresIn: Int, +) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.kt new file mode 100644 index 00000000..7633c77a --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoUserInfoResponse( + @field:JsonProperty("id") + val id: Long, + @field:JsonProperty("kakao_account") + val kakaoAccount: KakaoAccount, +) diff --git a/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt b/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt new file mode 100644 index 00000000..bb125002 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt @@ -0,0 +1,9 @@ +package com.weeth.global.auth.model + +/** + * Authentication 설정을 위한 model + */ +data class AuthenticatedUser( + val id: Long, + val email: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.kt b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.kt new file mode 100644 index 00000000..336a5fff --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.kt @@ -0,0 +1,42 @@ +package com.weeth.global.auth.resolver + +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException +import com.weeth.global.auth.model.AuthenticatedUser +import org.springframework.core.MethodParameter +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class CurrentUserArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + val hasAnnotation = parameter.hasParameterAnnotation(CurrentUser::class.java) + val parameterType = parameter.parameterType + val isLongType = parameterType == Long::class.java || parameterType == Long::class.javaPrimitiveType + return hasAnnotation && isLongType + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any { + val authentication = SecurityContextHolder.getContext().authentication + + if (authentication == null || authentication is AnonymousAuthenticationToken) { + throw AnonymousAuthenticationException() + } + + val principal = authentication.principal + + if (principal is AuthenticatedUser) { + return principal.id + } + + throw AnonymousAuthenticationException() + } +} diff --git a/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt new file mode 100644 index 00000000..60e2f038 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt @@ -0,0 +1,64 @@ +package com.weeth.global.common.controller + +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.attendance.application.exception.AttendanceErrorCode +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.comment.application.exception.CommentErrorCode +import com.weeth.domain.penalty.application.exception.PenaltyErrorCode +import com.weeth.domain.schedule.application.exception.EventErrorCode +import com.weeth.domain.session.application.exception.SessionErrorCode +import com.weeth.domain.user.application.exception.UserErrorCode +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v4/docs/exceptions") +@Tag(name = "Exception Document", description = "API 에러 코드 문서") +class ExceptionDocController { + @GetMapping("/account") + @Operation(summary = "Account 도메인 에러 코드 목록") + @ApiErrorCodeExample(AccountErrorCode::class) + fun accountErrorCodes() { + } + + @GetMapping("/attendance") + @Operation(summary = "Attendance 도메인 에러 코드 목록") + @ApiErrorCodeExample(AttendanceErrorCode::class, SessionErrorCode::class) + fun attendanceErrorCodes() { + } + + @GetMapping("/board") + @Operation(summary = "Board 도메인 에러 코드 목록") + @ApiErrorCodeExample(BoardErrorCode::class, CommentErrorCode::class) + fun boardErrorCodes() { + } + + @GetMapping("/penalty") + @Operation(summary = "Penalty 도메인 에러 코드 목록") + @ApiErrorCodeExample(PenaltyErrorCode::class) + fun penaltyErrorCodes() { + } + + @GetMapping("/schedule") + @Operation(summary = "Schedule 도메인 에러 코드 목록") + @ApiErrorCodeExample(EventErrorCode::class) + fun scheduleErrorCodes() { + } + + @GetMapping("/user") + @Operation(summary = "User 도메인 에러 코드 목록") + @ApiErrorCodeExample(UserErrorCode::class) + fun userErrorCodes() { + } + + @GetMapping("/auth") + @Operation(summary = "인증/인가 에러 코드 목록") + @ApiErrorCodeExample(JwtErrorCode::class) + fun authErrorCodes() { + } +} diff --git a/src/main/kotlin/com/weeth/global/common/controller/StatusCheckController.kt b/src/main/kotlin/com/weeth/global/common/controller/StatusCheckController.kt new file mode 100644 index 00000000..dccaec23 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/controller/StatusCheckController.kt @@ -0,0 +1,13 @@ +package com.weeth.global.common.controller + +import io.swagger.v3.oas.annotations.Hidden +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@Hidden +@RestController +class StatusCheckController { + @GetMapping("/health-check") + fun checkHealthStatus(): ResponseEntity = ResponseEntity.ok().build() +} diff --git a/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt new file mode 100644 index 00000000..c92ec17c --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt @@ -0,0 +1,24 @@ +package com.weeth.global.common.converter + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter +abstract class JsonConverter( + private val typeRef: TypeReference, +) : AttributeConverter { + companion object { + private val objectMapper = + ObjectMapper().apply { + registerModule(KotlinModule.Builder().build()) + } + } + + override fun convertToDatabaseColumn(attribute: T?): String? = + attribute?.let { objectMapper.writeValueAsString(it) } + + override fun convertToEntityAttribute(dbData: String?): T? = dbData?.let { objectMapper.readValue(it, typeRef) } +} diff --git a/src/main/kotlin/com/weeth/global/common/converter/PhoneNumberConverter.kt b/src/main/kotlin/com/weeth/global/common/converter/PhoneNumberConverter.kt new file mode 100644 index 00000000..44a63612 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/converter/PhoneNumberConverter.kt @@ -0,0 +1,12 @@ +package com.weeth.global.common.converter + +import com.weeth.global.common.vo.PhoneNumber +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter(autoApply = false) +class PhoneNumberConverter : AttributeConverter { + override fun convertToDatabaseColumn(attribute: PhoneNumber?): String = attribute?.value ?: "" + + override fun convertToEntityAttribute(dbData: String?): PhoneNumber = PhoneNumber.from(dbData ?: "") +} diff --git a/src/main/kotlin/com/weeth/global/common/entity/BaseEntity.kt b/src/main/kotlin/com/weeth/global/common/entity/BaseEntity.kt new file mode 100644 index 00000000..fe30d166 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/entity/BaseEntity.kt @@ -0,0 +1,22 @@ +package com.weeth.global.common.entity + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.MappedSuperclass +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime + +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +abstract class BaseEntity { + @CreatedDate + @Column(updatable = false) + var createdAt: LocalDateTime = LocalDateTime.MIN + protected set + + @LastModifiedDate + var modifiedAt: LocalDateTime = LocalDateTime.MIN + protected set +} diff --git a/src/main/kotlin/com/weeth/global/common/exception/ApiErrorCodeExample.kt b/src/main/kotlin/com/weeth/global/common/exception/ApiErrorCodeExample.kt new file mode 100644 index 00000000..3a7c3caf --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ApiErrorCodeExample.kt @@ -0,0 +1,9 @@ +package com.weeth.global.common.exception + +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class ApiErrorCodeExample( + vararg val value: KClass, +) diff --git a/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt b/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt new file mode 100644 index 00000000..db519072 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt @@ -0,0 +1,9 @@ +package com.weeth.global.common.exception + +abstract class BaseException( + val errorCode: ErrorCodeInterface, + message: String? = null, + val data: Any? = null, +) : RuntimeException(message ?: errorCode.message) { + val statusCode: Int get() = errorCode.status.value() +} diff --git a/src/main/kotlin/com/weeth/global/common/exception/BindExceptionResponse.kt b/src/main/kotlin/com/weeth/global/common/exception/BindExceptionResponse.kt new file mode 100644 index 00000000..ae6e6fcf --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/BindExceptionResponse.kt @@ -0,0 +1,6 @@ +package com.weeth.global.common.exception + +data class BindExceptionResponse( + val message: String?, + val value: Any?, +) diff --git a/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt new file mode 100644 index 00000000..7bf7f67e --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt @@ -0,0 +1,110 @@ +package com.weeth.global.common.exception + +import com.weeth.global.common.response.CommonResponse +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.http.ResponseEntity +import org.springframework.validation.BindException +import org.springframework.web.ErrorResponse +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException + +@RestControllerAdvice +class CommonExceptionHandler { + private val errorLog = LoggerFactory.getLogger("ERROR_LOG") + + @ExceptionHandler(BaseException::class) + fun handle(ex: BaseException): ResponseEntity> { + logException(ex.statusCode, ex, ex.message) + + val response = + if (ex.data != null) { + CommonResponse.error(ex.errorCode, ex.data) + } else { + CommonResponse.error(ex.errorCode) + } + + return ResponseEntity + .status(ex.statusCode) + .body(response) + } + + @ExceptionHandler(BindException::class) + fun handle(ex: BindException): ResponseEntity>> { + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 400 + val exceptionResponses = mutableListOf() + + if (ex is ErrorResponse) { + ex.bindingResult.fieldErrors.forEach { fieldError -> + exceptionResponses.add( + BindExceptionResponse( + message = fieldError.defaultMessage, + value = fieldError.rejectedValue, + ), + ) + } + } + + logException(statusCode, ex, exceptionResponses) + + val response = CommonResponse.createFailure(statusCode, "bindException", exceptionResponses.toList()) + + return ResponseEntity + .status(statusCode) + .body(response) + } + + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + fun handle(ex: MethodArgumentTypeMismatchException): ResponseEntity> { + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 400 + + logException(statusCode, ex, ex.message) + + val response = CommonResponse.createFailure(statusCode, INPUT_FORMAT_ERROR_MESSAGE) + + return ResponseEntity + .status(statusCode) + .body(response) + } + + @ExceptionHandler(Exception::class) + fun handle(ex: Exception): ResponseEntity> { + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 500 + + logException(statusCode, ex, ex.message, error = true) + + val response = CommonResponse.createFailure(statusCode, ex.message ?: "") + + return ResponseEntity + .status(statusCode) + .body(response) + } + + companion object { + private const val INPUT_FORMAT_ERROR_MESSAGE = "입력 포맷이 올바르지 않습니다." + private const val LOG_FORMAT = "Class : {}, Code : {}, Message : {}" + } + + private fun logException( + statusCode: Int, + ex: Throwable, + message: Any?, + error: Boolean = false, + ) { + try { + MDC.put("status", statusCode.toString()) + MDC.put("errorType", ex::class.simpleName ?: "Exception") + MDC.put("errorMessage", ex.message ?: message?.toString().orEmpty()) + if (error) { + errorLog.error(LOG_FORMAT, ex::class.simpleName, statusCode, message, ex) + } else { + errorLog.warn(LOG_FORMAT, ex::class.simpleName, statusCode, message) + } + } finally { + MDC.remove("status") + MDC.remove("errorType") + MDC.remove("errorMessage") + } + } +} diff --git a/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt b/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt new file mode 100644 index 00000000..497983f6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt @@ -0,0 +1,16 @@ +package com.weeth.global.common.exception + +import org.springframework.http.HttpStatus + +interface ErrorCodeInterface { + val code: Int + val status: HttpStatus + val message: String + + @Throws(NoSuchFieldException::class) + fun getExplainError(): String { + val field = this::class.java.getField((this as Enum<*>).name) + val annotation = field.getAnnotation(ExplainError::class.java) + return annotation?.value ?: message + } +} diff --git a/src/main/kotlin/com/weeth/global/common/exception/ExampleHolder.kt b/src/main/kotlin/com/weeth/global/common/exception/ExampleHolder.kt new file mode 100644 index 00000000..488f8acb --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ExampleHolder.kt @@ -0,0 +1,9 @@ +package com.weeth.global.common.exception + +import io.swagger.v3.oas.models.examples.Example + +data class ExampleHolder( + val holder: Example, + val name: String, + val code: Int, +) diff --git a/src/main/kotlin/com/weeth/global/common/exception/ExplainError.kt b/src/main/kotlin/com/weeth/global/common/exception/ExplainError.kt new file mode 100644 index 00000000..ae445e96 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ExplainError.kt @@ -0,0 +1,7 @@ +package com.weeth.global.common.exception + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class ExplainError( + val value: String = "", +) diff --git a/src/main/kotlin/com/weeth/global/common/id/TsidBase62Encoder.kt b/src/main/kotlin/com/weeth/global/common/id/TsidBase62Encoder.kt new file mode 100644 index 00000000..c9f00ae8 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/id/TsidBase62Encoder.kt @@ -0,0 +1,46 @@ +package com.weeth.global.common.id + +/** + * TSID를 Base62로 인코딩/디코딩하는 유틸리티 + * Base62 알파벳: 0-9a-zA-Z (총 62자) + */ +object TsidBase62Encoder { + private const val BASE62_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + private const val BASE = 62 + + /** + * Long을 Base62 String으로 인코딩 + */ + fun encode(id: Long): String { + if (id == 0L) return "0" + + val result = StringBuilder() + var num = id + + while (num > 0) { + result.append(BASE62_ALPHABET[(num % BASE).toInt()]) + num /= BASE + } + + return result.reverse().toString() + } + + /** + * Base62 String을 Long으로 디코딩 + */ + fun decode(encoded: String): Long { + if (encoded.isEmpty()) throw IllegalArgumentException("Base62 인코딩된 문자열은 비어 있을 수 없습니다.") + + var result = 0L + + for (char in encoded) { + val digit = BASE62_ALPHABET.indexOf(char) + if (digit == -1) { + throw IllegalArgumentException("유효하지 않은 Base62 문자: $char") + } + result = result * BASE + digit + } + + return result + } +} diff --git a/src/main/kotlin/com/weeth/global/common/id/TsidGenerator.kt b/src/main/kotlin/com/weeth/global/common/id/TsidGenerator.kt new file mode 100644 index 00000000..2c5b6c0c --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/id/TsidGenerator.kt @@ -0,0 +1,14 @@ +package com.weeth.global.common.id + +import io.hypersistence.tsid.TSID + +/** + * TSID (Time-Sorted Unique Identifier) 생성 유틸리티. + * 참고: 애플리케이션 단에서 ID를 할당하므로, 생성된 ID는 테스트에서 ReflectionTestUtils로 덮어씌울 수 있음. + */ +object TsidGenerator { + /** + * 새로운 TSID를 생성하여 Long 값으로 반환합니다. + */ + fun nextId(): Long = TSID.Factory.getTsid().toLong() +} diff --git a/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt b/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt index 31e9b13a..25a272f3 100644 --- a/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt +++ b/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt @@ -48,15 +48,6 @@ data class CommonResponse( data = data, ) - @JvmStatic - fun createSuccess(message: String): CommonResponse = success(message) - - @JvmStatic - fun createSuccess( - message: String, - data: T, - ): CommonResponse = success(message, data) - @JvmStatic fun error(errorCode: ErrorCodeInterface): CommonResponse = CommonResponse( diff --git a/src/main/kotlin/com/weeth/global/common/vo/PhoneNumber.kt b/src/main/kotlin/com/weeth/global/common/vo/PhoneNumber.kt new file mode 100644 index 00000000..979c21d1 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/vo/PhoneNumber.kt @@ -0,0 +1,16 @@ +package com.weeth.global.common.vo + +data class PhoneNumber private constructor( + val value: String, +) { + companion object { + fun from(raw: String): PhoneNumber { + val normalized = raw.filter { it.isDigit() } + if (normalized.isBlank()) { + return PhoneNumber("") + } + require(normalized.length in 10..11) { "Invalid phone number format." } + return PhoneNumber(normalized) + } + } +} diff --git a/src/main/kotlin/com/weeth/global/common/web/TsidParam.kt b/src/main/kotlin/com/weeth/global/common/web/TsidParam.kt new file mode 100644 index 00000000..612f9f4f --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/web/TsidParam.kt @@ -0,0 +1,16 @@ +package com.weeth.global.common.web + +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.Schema + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +@Parameter( + description = "Base62 인코딩 TSID", + example = "1zA9", + required = true, + `in` = ParameterIn.PATH, + schema = Schema(type = "string"), +) +annotation class TsidParam diff --git a/src/main/kotlin/com/weeth/global/common/web/TsidPathVariable.kt b/src/main/kotlin/com/weeth/global/common/web/TsidPathVariable.kt new file mode 100644 index 00000000..d8ab79d9 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/web/TsidPathVariable.kt @@ -0,0 +1,7 @@ +package com.weeth.global.common.web + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class TsidPathVariable( + val value: String = "", +) diff --git a/src/main/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolver.kt b/src/main/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolver.kt new file mode 100644 index 00000000..33109468 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolver.kt @@ -0,0 +1,73 @@ +package com.weeth.global.common.web + +import com.weeth.global.common.id.TsidBase62Encoder +import org.springframework.core.MethodParameter +import org.springframework.web.bind.MissingPathVariableException +import org.springframework.web.bind.ServletRequestBindingException +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.context.request.RequestAttributes +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import org.springframework.web.servlet.HandlerMapping + +/** + * `@TsidPathVariable`가 선언된 path variable을 Base62 TSID 문자열에서 Long 값으로 변환한다. + * + * 제약: + * - 파라미터 타입은 `Long` 또는 `long`만 지원한다. + * - 어노테이션 `value`가 비어 있으면 파라미터 이름을 path variable 이름으로 사용한다. + * - 디코딩 실패 시 `InvalidTsidPathVariableException`을 던진다. + */ +class TsidPathVariableArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + val hasAnnotation = parameter.hasParameterAnnotation(TsidPathVariable::class.java) + val parameterType = parameter.parameterType + val isLongType = parameterType == Long::class.java || parameterType == Long::class.javaPrimitiveType + return hasAnnotation && isLongType + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any { + val annotation = + parameter.getParameterAnnotation(TsidPathVariable::class.java) + ?: throw IllegalStateException("@TsidPathVariable 어노테이션이 필요합니다.") + + val variableName = + annotation.value.ifBlank { + parameter.parameterName ?: throw IllegalStateException("PathVariable 이름을 해석할 수 없습니다.") + } + + val uriVariables = + webRequest.getAttribute( + HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, + RequestAttributes.SCOPE_REQUEST, + ) as? Map<*, *> ?: emptyMap() + + val rawValue = + uriVariables[variableName] as? String ?: throw MissingPathVariableException(variableName, parameter) + + return try { + TsidBase62Encoder.decode(rawValue) + } catch (e: IllegalArgumentException) { + throw InvalidTsidPathVariableException( + variableName = variableName, + value = rawValue, + cause = e, + ) + } + } +} + +class InvalidTsidPathVariableException( + val variableName: String, + val value: String, + cause: Throwable? = null, +) : ServletRequestBindingException( + "유효하지 않은 TSID 경로 변수 '$variableName': $value", + cause, + ) diff --git a/src/main/kotlin/com/weeth/global/config/AsyncConfig.kt b/src/main/kotlin/com/weeth/global/config/AsyncConfig.kt new file mode 100644 index 00000000..c9fffaa9 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/AsyncConfig.kt @@ -0,0 +1,19 @@ +package com.weeth.global.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import java.util.concurrent.Executor + +@Configuration +class AsyncConfig { + @Bean(name = ["taskExecutor"]) + fun taskExecutor(): Executor = + ThreadPoolTaskExecutor().apply { + corePoolSize = 5 + maxPoolSize = 10 + queueCapacity = 50 + setThreadNamePrefix("async-") + initialize() + } +} diff --git a/src/main/kotlin/com/weeth/global/config/AwsS3Config.kt b/src/main/kotlin/com/weeth/global/config/AwsS3Config.kt new file mode 100644 index 00000000..bc8feeb6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/AwsS3Config.kt @@ -0,0 +1,28 @@ +package com.weeth.global.config + +import com.weeth.global.config.properties.AwsS3Properties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.presigner.S3Presigner + +@Configuration +class AwsS3Config( + private val awsS3Properties: AwsS3Properties, +) { + @Bean + fun s3Presigner(): S3Presigner { + val credentials = + AwsBasicCredentials.create( + awsS3Properties.credentials.accessKey, + awsS3Properties.credentials.secretKey, + ) + return S3Presigner + .builder() + .region(Region.of(awsS3Properties.region.static)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build() + } +} diff --git a/src/main/kotlin/com/weeth/global/config/CacheConfig.kt b/src/main/kotlin/com/weeth/global/config/CacheConfig.kt new file mode 100644 index 00000000..4e65c564 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/CacheConfig.kt @@ -0,0 +1,57 @@ +package com.weeth.global.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator +import org.springframework.cache.annotation.EnableCaching +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.cache.RedisCacheConfiguration +import org.springframework.data.redis.cache.RedisCacheManager +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.RedisSerializationContext +import org.springframework.data.redis.serializer.StringRedisSerializer +import java.time.Duration + +/* + Spring Cache 추상화(@Cacheable)를 Redis와 연결하는 설정 + 키: String, 값: JSON 직렬화, 기본 TTL: 7일 + */ +@EnableCaching +@Configuration +class CacheConfig( + private val redisConnectionFactory: RedisConnectionFactory, + private val objectMapper: ObjectMapper, +) { + @Bean + fun cacheManager(): RedisCacheManager { + // Spring Boot 자동 구성 ObjectMapper(KotlinModule 포함)를 기반으로 + // 타입 정보(@class)를 포함한 Redis 전용 ObjectMapper 생성 + val redisObjectMapper = + objectMapper.copy().activateDefaultTyping( + BasicPolymorphicTypeValidator + .builder() + .allowIfSubType("com.weeth.") + .allowIfSubType(java.util.ArrayList::class.java) + .build(), + ObjectMapper.DefaultTyping.NON_FINAL, + ) + + val defaultConfig = + RedisCacheConfiguration + .defaultCacheConfig() + .entryTtl(Duration.ofDays(7)) + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()), + ).serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + GenericJackson2JsonRedisSerializer(redisObjectMapper), + ), + ) + + return RedisCacheManager + .builder(redisConnectionFactory) + .cacheDefaults(defaultConfig) + .build() + } +} diff --git a/src/main/kotlin/com/weeth/global/config/ObservabilityConfig.kt b/src/main/kotlin/com/weeth/global/config/ObservabilityConfig.kt new file mode 100644 index 00000000..58fd92e5 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/ObservabilityConfig.kt @@ -0,0 +1,28 @@ +package com.weeth.global.config + +import io.micrometer.observation.ObservationPredicate +import io.micrometer.observation.ObservationRegistry +import org.springframework.boot.web.client.RestClientCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.server.observation.ServerRequestObservationContext + +@Configuration +class ObservabilityConfig { + @Bean + fun restClientObservationCustomizer(observationRegistry: ObservationRegistry): RestClientCustomizer = + RestClientCustomizer { builder -> + builder.observationRegistry(observationRegistry) + } + + @Bean + fun actuatorObservationPredicate(): ObservationPredicate = + ObservationPredicate { _, context -> + if (context is ServerRequestObservationContext) { + val path = context.carrier?.requestURI ?: return@ObservationPredicate true + return@ObservationPredicate !path.startsWith("/actuator") && !path.startsWith("/health-check") + } + + true + } +} diff --git a/src/main/kotlin/com/weeth/global/config/RedisConfig.kt b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt new file mode 100644 index 00000000..768351fa --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt @@ -0,0 +1,69 @@ +package com.weeth.global.config + +import com.weeth.domain.attendance.infrastructure.QrExpiredEventListener +import com.weeth.global.config.properties.RedisProperties +import io.lettuce.core.metrics.MicrometerCommandLatencyRecorder +import io.lettuce.core.metrics.MicrometerOptions +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.RedisKeyValueAdapter +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.listener.PatternTopic +import org.springframework.data.redis.listener.RedisMessageListenerContainer +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) +class RedisConfig( + private val redisProperties: RedisProperties, + private val meterRegistry: MeterRegistry, +) { + @Bean + fun redisConnectionFactory(): RedisConnectionFactory { + val redisConfiguration = + RedisStandaloneConfiguration().apply { + hostName = redisProperties.host + port = redisProperties.port + if (!redisProperties.password.isNullOrEmpty()) { + setPassword(redisProperties.password) + } + } + + val clientConfig = + LettuceClientConfiguration + .builder() + .clientResources( + io.lettuce.core.resource.ClientResources + .builder() + .commandLatencyRecorder( + MicrometerCommandLatencyRecorder(meterRegistry, MicrometerOptions.create()), + ).build(), + ).build() + + return LettuceConnectionFactory(redisConfiguration, clientConfig) + } + + @Bean + fun redisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate = + RedisTemplate().apply { + keySerializer = StringRedisSerializer() + valueSerializer = StringRedisSerializer() + connectionFactory = redisConnectionFactory + } + + @Bean + fun redisMessageListenerContainer( + redisConnectionFactory: RedisConnectionFactory, + qrKeyExpiredListener: QrExpiredEventListener, + ): RedisMessageListenerContainer = + RedisMessageListenerContainer().apply { + setConnectionFactory(redisConnectionFactory) + addMessageListener(qrKeyExpiredListener, PatternTopic("__keyevent@0__:expired")) + } +} diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt new file mode 100644 index 00000000..4bd4f248 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -0,0 +1,110 @@ +package com.weeth.global.config + +import com.weeth.global.auth.authentication.CustomAccessDeniedHandler +import com.weeth.global.auth.authentication.CustomAuthenticationEntryPoint +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.auth.jwt.filter.JwtAuthenticationProcessingFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.security.authorization.AuthorizationDecision +import org.springframework.security.config.Customizer.withDefaults +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +class SecurityConfig( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtTokenExtractor: JwtTokenExtractor, + private val customAuthenticationEntryPoint: CustomAuthenticationEntryPoint, + private val customAccessDeniedHandler: CustomAccessDeniedHandler, +) { + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain = + http + .formLogin { it.disable() } + .httpBasic { it.disable() } + .cors(withDefaults()) + .csrf { it.disable() } + .headers { headers -> + headers.frameOptions { frameOptions -> frameOptions.sameOrigin() } + }.sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { authorize -> + authorize + .requestMatchers( + "/api/v4/users/social/kakao", + "/api/v4/users/social/apple", + "/api/v4/users/social/apple/callback", + "/api/v4/users/social/refresh", + "/api/v4/users/inquiries", + ).permitAll() + .requestMatchers("/health-check") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/v4/clubs/*") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/v4/university/*") + .permitAll() + .requestMatchers( + "/v3/api-docs", + "/v3/api-docs/**", + "/swagger-ui.html", + "/swagger-ui/**", + "/swagger/**", + ).permitAll() + .requestMatchers("/actuator/health") + .permitAll() + .requestMatchers("/actuator/**") + .access { _, context -> + val address = java.net.InetAddress.getByName(context.request.remoteAddr) + AuthorizationDecision(address.isLoopbackAddress || address.isSiteLocalAddress) + }.requestMatchers("/api/v4/users/terms") + .hasAnyRole("TEMPORARY", "USER") + .anyRequest() + .hasRole("USER") + }.exceptionHandling { exceptionHandling -> + exceptionHandling + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) + }.addFilterBefore(jwtAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter::class.java) + .build() + + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + val configuration = + CorsConfiguration().apply { + allowedOriginPatterns = + listOf( + "http://localhost:*", + "http://127.0.0.1:*", + "https://*.v4.weeth.kr", + "https://landing.weeth.kr", + "https://www.landing.weeth.kr", + "https://weeth.kr", + "https://www.weeth.kr", + "https://appleid.apple.com", + ) + allowedMethods = listOf("GET", "POST", "PATCH", "DELETE", "OPTIONS") + allowedHeaders = listOf("*") + exposedHeaders = listOf("Authorization", "Authorization_refresh") + allowCredentials = true + } + + return UrlBasedCorsConfigurationSource().apply { + registerCorsConfiguration("/**", configuration) + } + } + + @Bean + fun jwtAuthenticationProcessingFilter(): JwtAuthenticationProcessingFilter = + JwtAuthenticationProcessingFilter(jwtTokenProvider, jwtTokenExtractor) +} diff --git a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt new file mode 100644 index 00000000..6bd31f9a --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt @@ -0,0 +1,186 @@ +package com.weeth.global.config + +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExampleHolder +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.config.properties.JwtProperties +import io.swagger.v3.oas.annotations.OpenAPIDefinition +import io.swagger.v3.oas.annotations.info.Info +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.examples.Example +import io.swagger.v3.oas.models.media.Content +import io.swagger.v3.oas.models.media.MediaType +import io.swagger.v3.oas.models.responses.ApiResponse +import io.swagger.v3.oas.models.responses.ApiResponses +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import org.springdoc.core.customizers.OperationCustomizer +import org.springdoc.core.models.GroupedOpenApi +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.HandlerMethod + +private const val SWAGGER_DESCRIPTION = + "## Response Code 규칙 (5자리: XDDNN)\n" + + "- **X**: 1=Success, 2=Domain Error, 3=Infra/Server Error, 4=Client/Validation Error\n" + + "- **DD**: 도메인 ID (2자리)\n" + + "- **NN**: 도메인 내 순번 (00~99)\n\n" + + "## 도메인별 코드 범위\n" + + "| Domain | DD | Success | Domain Error | Infra Error |\n" + + "|--------|----|---------|-------------|-------------|\n" + + "| Account | 01 | 101xx | 201xx | — |\n" + + "| Attendance | 02 | 102xx | 202xx | — |\n" + + "| Session | 03 | 103xx | 203xx | — |\n" + + "| Board | 04 | 104xx | 204xx | — |\n" + + "| Comment | 05 | 105xx | 205xx | — |\n" + + "| File | 06 | 106xx | 206xx | 306xx |\n" + + "| Penalty | 07 | 107xx | 207xx | — |\n" + + "| Schedule | 08 | 108xx | 208xx | — |\n" + + "| User | 09 | 109xx | 209xx | — |\n" + + "| Cardinal | 10 | 110xx | 210xx | — |\n" + + "| Club | 11 | 111xx | 211xx | — |\n" + + "| Dashboard | 12 | 112xx | 212xx | — |\n" + + "| University | 13 | 113xx | — | 313xx |\n" + + "| Auth/JWT | 90 | — | 290xx | — |\n\n" + + "> 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요." + +@Configuration +@OpenAPIDefinition( + info = + Info( + title = "Weeth API", + version = "v4.0.0", + description = SWAGGER_DESCRIPTION, + ), +) +class SwaggerConfig( + private val jwtProperties: JwtProperties, +) { + @Bean + fun openAPI(): OpenAPI { + val accessSecurityScheme = getAccessSecurityScheme() + val refreshSecurityScheme = getRefreshSecurityScheme() + + return OpenAPI() + .addServersItem(Server().url("/")) + .components( + Components() + .addSecuritySchemes("bearerAuth", accessSecurityScheme) + .addSecuritySchemes("refreshBearerAuth", refreshSecurityScheme), + ).security( + listOf( + SecurityRequirement().addList("bearerAuth"), + SecurityRequirement().addList("refreshBearerAuth"), + ), + ) + } + + @Bean + fun adminApi(): GroupedOpenApi = + GroupedOpenApi + .builder() + .group("admin") + .pathsToMatch("/api/v1/admin/**", "/api/v4/admin/**") + .addOperationCustomizer(operationCustomizer()) + .build() + + @Bean + fun publicApi(): GroupedOpenApi = + GroupedOpenApi + .builder() + .group("public") + .pathsToExclude("/api/v1/admin/**", "/api/v4/admin/**") + .addOperationCustomizer(operationCustomizer()) + .build() + + @Bean + fun operationCustomizer(): OperationCustomizer = + OperationCustomizer { operation, handlerMethod -> + val apiErrorCodeExample = findAnnotation(handlerMethod, ApiErrorCodeExample::class.java) + if (apiErrorCodeExample != null) { + apiErrorCodeExample.value.forEach { type -> + generateErrorCodeResponseExample(operation.responses, type.java) + } + } + + operation + } + + private fun generateErrorCodeResponseExample( + responses: ApiResponses, + type: Class, + ) { + val errorCodes = type.enumConstants ?: return + + val statusWithExampleHolders = + errorCodes + .map { errorCode -> + val enumName = (errorCode as Enum<*>).name + val description = runCatching { errorCode.getExplainError() }.getOrDefault(errorCode.message) + + ExampleHolder( + holder = getSwaggerExample(description, errorCode), + code = errorCode.status.value(), + name = "[$enumName] ${errorCode.message}", + ) + }.groupBy { it.code } + + addExamplesToResponses(responses, statusWithExampleHolders) + } + + private fun getSwaggerExample( + description: String, + errorCode: ErrorCodeInterface, + ): Example { + val errorResponse = CommonResponse.Companion.createFailure(errorCode.code, errorCode.message) + return Example() + .description(description) + .value(errorResponse) + } + + private fun addExamplesToResponses( + responses: ApiResponses, + statusWithExampleHolders: Map>, + ) { + statusWithExampleHolders.forEach { (status, exampleHolders) -> + val apiResponse = responses.computeIfAbsent(status.toString()) { ApiResponse() } + val mediaType = getOrCreateMediaType(apiResponse) + exampleHolders.forEach { holder -> mediaType.addExamples(holder.name, holder.holder) } + } + } + + private fun findAnnotation( + handlerMethod: HandlerMethod, + annotationType: Class, + ): A? { + val annotation = handlerMethod.getMethodAnnotation(annotationType) + if (annotation != null) { + return annotation + } + return handlerMethod.beanType.getAnnotation(annotationType) + } + + private fun getOrCreateMediaType(apiResponse: ApiResponse): MediaType { + val content = apiResponse.content ?: Content().also { apiResponse.content = it } + return content["application/json"] ?: MediaType().also { content.addMediaType("application/json", it) } + } + + private fun getAccessSecurityScheme(): SecurityScheme = + SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .`in`(SecurityScheme.In.HEADER) + .name(jwtProperties.access.header) + + private fun getRefreshSecurityScheme(): SecurityScheme = + SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .scheme("bearer") + .bearerFormat("JWT") + .`in`(SecurityScheme.In.HEADER) + .name(jwtProperties.refresh.header) +} diff --git a/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt b/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt new file mode 100644 index 00000000..87c9be2c --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt @@ -0,0 +1,15 @@ +package com.weeth.global.config + +import com.weeth.global.auth.resolver.CurrentUserArgumentResolver +import com.weeth.global.common.web.TsidPathVariableArgumentResolver +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(CurrentUserArgumentResolver()) + resolvers.add(TsidPathVariableArgumentResolver()) + } +} diff --git a/src/main/kotlin/com/weeth/global/config/properties/CareerNetProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/CareerNetProperties.kt new file mode 100644 index 00000000..cd852f54 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/properties/CareerNetProperties.kt @@ -0,0 +1,14 @@ +package com.weeth.global.config.properties + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "career-net") +data class CareerNetProperties( + @field:NotBlank + val key: String, + @field:NotBlank + val baseUrl: String, +) diff --git a/src/main/kotlin/com/weeth/global/config/properties/CookieProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/CookieProperties.kt new file mode 100644 index 00000000..d679d30d --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/properties/CookieProperties.kt @@ -0,0 +1,20 @@ +package com.weeth.global.config.properties + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "weeth.cookie") +data class CookieProperties( + @field:NotBlank + val accessTokenName: String, + @field:NotBlank + val refreshTokenName: String, + val domain: String = "", + val path: String = "/", + val refreshPath: String = "/api/v4/users/social/refresh", + val sameSite: String = "Lax", + val secure: Boolean = true, + val httpOnly: Boolean = true, +) diff --git a/src/main/kotlin/com/weeth/global/config/properties/NotionProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/NotionProperties.kt new file mode 100644 index 00000000..e0c10158 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/properties/NotionProperties.kt @@ -0,0 +1,13 @@ +package com.weeth.global.config.properties + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "notion") +data class NotionProperties( + @field:NotBlank val token: String, + @field:NotBlank val version: String, + @field:NotBlank val inquiryDatabaseId: String, +) diff --git a/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt index 9d845342..d96ea1c4 100644 --- a/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt +++ b/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt @@ -40,5 +40,7 @@ data class OAuthProperties( val keysUri: String, @field:NotBlank val privateKeyPath: String, + @field:NotBlank + val frontendRedirectUri: String, ) } diff --git a/src/main/kotlin/com/weeth/global/config/properties/SlackProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/SlackProperties.kt new file mode 100644 index 00000000..a90636ac --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/properties/SlackProperties.kt @@ -0,0 +1,11 @@ +package com.weeth.global.config.properties + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "slack") +data class SlackProperties( + @field:NotBlank val webhookUrl: String, +) diff --git a/src/main/kotlin/com/weeth/global/logging/AccessLogFilter.kt b/src/main/kotlin/com/weeth/global/logging/AccessLogFilter.kt new file mode 100644 index 00000000..24f4aff8 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/logging/AccessLogFilter.kt @@ -0,0 +1,115 @@ +package com.weeth.global.logging + +import io.opentelemetry.api.trace.Span +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.util.UUID + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 2) +class AccessLogFilter : OncePerRequestFilter() { + private val accessLog = LoggerFactory.getLogger("ACCESS_LOG") + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val requestId = + request.getHeader("X-Request-Id") + ?: UUID + .randomUUID() + .toString() + val startTime = System.currentTimeMillis() + + var failed = false + try { + MDC.put("requestId", requestId) + MDC.put("path", request.requestURI) + MDC.put("method", request.method) + putTraceMdc(request) + response.setHeader("X-Request-Id", requestId) + filterChain.doFilter(request, response) + } catch (ex: Throwable) { + failed = true + throw ex + } finally { + val durationMs = System.currentTimeMillis() - startTime + val status = if (failed) "500" else response.status.toString() + MDC.put("status", status) + MDC.put("durationMs", durationMs.toString()) + putTraceMdc(request) + + accessLog.info("HTTP Request Completed") + + MDC.remove("requestId") + MDC.remove("path") + MDC.remove("method") + MDC.remove("status") + MDC.remove("durationMs") + MDC.remove("traceId") + MDC.remove("spanId") + MDC.remove("userId") + } + } + + override fun shouldNotFilter(request: HttpServletRequest): Boolean { + val path = request.requestURI + return path.startsWith("/actuator") || path.startsWith("/health-check") + } + + private fun putTraceMdc(request: HttpServletRequest) { + if (!MDC.get("traceId").isNullOrBlank() && !MDC.get("spanId").isNullOrBlank()) { + return + } + + val context = Span.current().spanContext + if (context.isValid) { + putTraceContext(context.traceId, context.spanId) + return + } + + putTraceparentContext(request.getHeader(TRACEPARENT_HEADER)) + } + + private fun putTraceContext( + traceId: String?, + spanId: String?, + ) { + if (!traceId.isNullOrBlank()) { + MDC.put("traceId", traceId) + } + if (!spanId.isNullOrBlank()) { + MDC.put("spanId", spanId) + } + } + + private fun putTraceparentContext(traceparent: String?) { + val parts = traceparent?.split("-") ?: return + if (parts.size < TRACEPARENT_PARTS) { + return + } + + val traceId = parts[TRACEPARENT_TRACE_ID_INDEX] + val spanId = parts[TRACEPARENT_SPAN_ID_INDEX] + if (traceId.length == TRACE_ID_LENGTH && spanId.length == SPAN_ID_LENGTH) { + putTraceContext(traceId, spanId) + } + } + + companion object { + private const val TRACEPARENT_HEADER = "traceparent" + private const val TRACEPARENT_PARTS = 4 + private const val TRACEPARENT_TRACE_ID_INDEX = 1 + private const val TRACEPARENT_SPAN_ID_INDEX = 2 + private const val TRACE_ID_LENGTH = 32 + private const val SPAN_ID_LENGTH = 16 + } +} diff --git a/src/main/kotlin/com/weeth/global/logging/MaskingJsonGeneratorDecorator.kt b/src/main/kotlin/com/weeth/global/logging/MaskingJsonGeneratorDecorator.kt new file mode 100644 index 00000000..fc3481e0 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/logging/MaskingJsonGeneratorDecorator.kt @@ -0,0 +1,51 @@ +package com.weeth.global.logging + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.util.JsonGeneratorDelegate +import net.logstash.logback.decorate.JsonGeneratorDecorator + +class MaskingJsonGeneratorDecorator : JsonGeneratorDecorator { + override fun decorate(generator: JsonGenerator): JsonGenerator = MaskingJsonGenerator(generator) +} + +class MaskingJsonGenerator( + delegate: JsonGenerator, +) : JsonGeneratorDelegate(delegate) { + private val sensitiveFields = setOf("password", "token", "accesstoken", "refreshtoken", "secret", "authorization") + private var currentFieldName: String? = null + + override fun writeFieldName(name: String?) { + currentFieldName = name?.lowercase()?.filter(Char::isLetterOrDigit) + super.writeFieldName(name) + } + + override fun writeString(text: String?) { + if (text == null) { + super.writeString(null as String?) + return + } + + val masked = + when { + currentFieldName in sensitiveFields -> "***" + else -> maskPatterns(text) + } + currentFieldName = null + super.writeString(masked as String?) + } + + private fun maskPatterns(value: String): String = + value + .replace(EMAIL_PATTERN) { "${it.groupValues[1]}***${it.groupValues[3]}" } + .replace(PHONE_PATTERN) { "${it.groupValues[1]}-****-${it.groupValues[3]}" } + .replace(TOKEN_PATTERN) { "${it.groupValues[1]}***" } + + companion object { + private val EMAIL_PATTERN = + Regex("""([a-zA-Z0-9._%+-])([a-zA-Z0-9._%+-]*)(@[a-zA-Z0-9.-]+)""") + private val PHONE_PATTERN = + Regex("""(01[0-9])-?(\d{3,4})-?(\d{4})""") + private val TOKEN_PATTERN = + Regex("""(eyJ[a-zA-Z0-9_-]{7})[a-zA-Z0-9_.-]+""") + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b8135b57..9a96e8d6 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -27,6 +27,12 @@ weeth: refresh: expiration: ${REFRESH_EXP} header: ${REFRESH_HEAD} + cookie: + access-token-name: ${ACCESS_HEAD} + refresh-token-name: ${REFRESH_HEAD} + domain: ${COOKIE_DOMAIN:} + same-site: ${COOKIE_SAME_SITE:Lax} + secure: false cloud: aws: s3: @@ -38,4 +44,4 @@ cloud: static: ap-northeast-2 auto: false stack: - auto: false \ No newline at end of file + auto: false diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 4d47ca69..fe67cc3d 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -27,6 +27,12 @@ weeth: refresh: expiration: ${REFRESH_EXP} header: ${REFRESH_HEAD} + cookie: + access-token-name: ${ACCESS_HEAD} + refresh-token-name: ${REFRESH_HEAD} + domain: ${COOKIE_DOMAIN:} + same-site: ${COOKIE_SAME_SITE:Lax} + secure: false cloud: aws: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 8d89246b..e60408ca 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -32,6 +32,12 @@ weeth: refresh: expiration: ${REFRESH_EXP} header: ${REFRESH_HEAD} + cookie: + access-token-name: ${ACCESS_HEAD} + refresh-token-name: ${REFRESH_HEAD} + domain: ${COOKIE_DOMAIN:} + same-site: ${COOKIE_SAME_SITE:Lax} + secure: true cloud: aws: s3: @@ -43,4 +49,4 @@ cloud: static: ap-northeast-2 auto: false stack: - auto: false \ No newline at end of file + auto: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9c079427..47388035 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,18 @@ +server: + tomcat: + mbeanregistry: # Tomcat Threads 확인을 위한 설정 + enabled: true + spring: + application: + name: weeth-server profiles: active: local + group: + local-monitoring: + - local + jpa: + open-in-view: false springdoc: use-fqn: true @@ -26,6 +38,7 @@ auth: token_uri: https://appleid.apple.com/auth/token keys_uri: https://appleid.apple.com/auth/keys private_key_path: ${APPLE_PRIVATE_KEY_PATH} + frontend_redirect_uri: ${APPLE_FRONTEND_REDIRECT_URI} management: endpoints: @@ -34,7 +47,41 @@ management: include: - health - prometheus + - info + endpoint: + health: + show-details: when-authorized + info: + env: + enabled: true + build: + enabled: true prometheus: metrics: export: enabled: true + metrics: + tags: + application: weeth-server + distribution: + percentiles-histogram: + http.server.requests: true + slo: + http.server.requests: 50ms,100ms,200ms,500ms,1s + +app: + file: + cdn-base-url: ${CDN_BASE_URL:} + presigned-url-expiration-minutes: 5 + +career-net: + key: ${CAREER_NET_API_KEY} + base-url: https://www.career.go.kr/cnet/openapi/getOpenApi + +slack: + webhook-url: ${SLACK_WEBHOOK_URL} + +notion: + token: ${NOTION_TOKEN} + version: ${NOTION_VERSION} + inquiry-database-id: ${NOTION_INQUIRY_DATABASE_ID} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..af67b860 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,65 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + requestId + traceId + spanId + trace_id + span_id + userId + path + method + status + durationMs + errorType + errorMessage + + + + + + + + + + + + + + + + http://localhost:${LOKI_PORT}/loki/api/v1/push + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/kotlin/com/weeth/config/CacheBenchmarkUtil.kt b/src/test/kotlin/com/weeth/config/CacheBenchmarkUtil.kt new file mode 100644 index 00000000..884c92d5 --- /dev/null +++ b/src/test/kotlin/com/weeth/config/CacheBenchmarkUtil.kt @@ -0,0 +1,70 @@ +package com.weeth.config + +import kotlin.system.measureTimeMillis + +/** + * Redis Cache 캐시 미스/히트 성능 측정 유틸. + * + * - [benchmarkRounds]: 1번 실제 호출(캐시 미스) + N-1번 캐시 히트로 성능 차이를 측정한다. + * 캐싱 없이 N번 호출했을 경우의 총 시간은 실측값이 아닌 추정치(missTimeMs × N)이다. + * + * 사용법: + * ``` + * val result = CacheBenchmarkUtil.benchmarkRounds( + * cacheName = "schools", + * rounds = 20, + * clearCache = { cacheManager.getCache("schools")?.clear() }, + * ) { getUniversityQueryService.getSchools() } + * println(result) + * result.totalWithCacheMs shouldBeLessThan result.estimatedTotalWithoutCacheMs + * ``` + */ +object CacheBenchmarkUtil { + data class MultiRoundResult( + val cacheName: String, + val rounds: Int, + val missTimeMs: Long, + val avgHitTimeMs: Long, + ) { + // 실측값이 아닌 추정치: 캐싱 없이 N번 호출한다고 가정한 예상 시간 + val estimatedTotalWithoutCacheMs: Long get() = missTimeMs * rounds + val totalWithCacheMs: Long get() = missTimeMs + avgHitTimeMs * (rounds - 1) + val hitRate: Double get() = (rounds - 1).toDouble() / rounds * 100 + val speedup: Long get() = estimatedTotalWithoutCacheMs / totalWithCacheMs.coerceAtLeast(1) + + override fun toString(): String { + val maxMs = estimatedTotalWithoutCacheMs.coerceAtLeast(1) + val noBar = "█".repeat((estimatedTotalWithoutCacheMs * BAR_WIDTH / maxMs).toInt().coerceAtLeast(1)) + val withBar = "█".repeat((totalWithCacheMs * BAR_WIDTH / maxMs).toInt().coerceAtLeast(1)) + return """ + |[CacheBenchmark][$cacheName] $rounds rounds + | without cache │$noBar ~${estimatedTotalWithoutCacheMs}ms (${missTimeMs}ms × $rounds 추정) + | with cache │$withBar ${totalWithCacheMs}ms (${missTimeMs}ms + ${rounds - 1} × ${avgHitTimeMs}ms) + | hit rate: ${"%.1f".format(hitRate)}% (${rounds - 1}/$rounds) + | speedup: ${speedup}x + """.trimMargin() + } + } + + private const val BAR_WIDTH = 40 + + fun benchmarkRounds( + cacheName: String, + rounds: Int, + clearCache: () -> Unit, + block: () -> Unit, + ): MultiRoundResult { + clearCache() + val missTimeMs = measureTimeMillis { block() } + + val hitTimes = (1 until rounds).map { measureTimeMillis { block() } } + val avgHitTimeMs = if (hitTimes.isEmpty()) 0L else hitTimes.average().toLong() + + return MultiRoundResult( + cacheName = cacheName, + rounds = rounds, + missTimeMs = missTimeMs, + avgHitTimeMs = avgHitTimeMs, + ) + } +} diff --git a/src/test/kotlin/com/weeth/config/QueryCountUtil.kt b/src/test/kotlin/com/weeth/config/QueryCountUtil.kt new file mode 100644 index 00000000..449b50cb --- /dev/null +++ b/src/test/kotlin/com/weeth/config/QueryCountUtil.kt @@ -0,0 +1,57 @@ +package com.weeth.config + +import jakarta.persistence.EntityManager +import org.hibernate.SessionFactory + +/** + * Hibernate Statistics 기반 쿼리 카운터. + * 블록 실행 중 발생한 SQL prepared statement 수를 반환한다. + * + * 사용법: + * ``` + * val result = QueryCountUtil.count(entityManager) { + * repository.findById(id) + * } + * result.queryCount shouldBe 1 + * ``` + */ +object QueryCountUtil { + data class Result( + val queryCount: Long, + val entityLoadCount: Long, + val collectionLoadCount: Long, + val elapsedTimeMs: Double, + ) { + override fun toString(): String = + "queries=$queryCount, entityLoads=$entityLoadCount, collectionLoads=$collectionLoadCount, elapsedMs=%.3f" + .format( + elapsedTimeMs, + ) + } + + fun count( + entityManager: EntityManager, + block: () -> Unit, + ): Result { + val sessionFactory = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java) + val stats = sessionFactory.statistics + + stats.isStatisticsEnabled = true + stats.clear() + + val startNanos = System.nanoTime() + block() + val elapsedNanos = System.nanoTime() - startNanos + + val result = + Result( + queryCount = stats.prepareStatementCount, + entityLoadCount = stats.entityLoadCount, + collectionLoadCount = stats.collectionFetchCount, + elapsedTimeMs = elapsedNanos / 1_000_000.0, + ) + + stats.isStatisticsEnabled = false + return result + } +} diff --git a/src/test/kotlin/com/weeth/config/TestContainersConfig.kt b/src/test/kotlin/com/weeth/config/TestContainersConfig.kt index 23c5dd1f..70c0403d 100644 --- a/src/test/kotlin/com/weeth/config/TestContainersConfig.kt +++ b/src/test/kotlin/com/weeth/config/TestContainersConfig.kt @@ -3,6 +3,7 @@ package com.weeth.config import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.testcontainers.service.connection.ServiceConnection import org.springframework.context.annotation.Bean +import org.testcontainers.containers.GenericContainer import org.testcontainers.containers.MySQLContainer import org.testcontainers.utility.DockerImageName @@ -12,7 +13,14 @@ class TestContainersConfig { @ServiceConnection fun mysqlContainer(): MySQLContainer<*> = MySQLContainer(DockerImageName.parse(MYSQL_IMAGE)) + @Bean + @ServiceConnection(name = "redis") + fun redisContainer(): GenericContainer<*> = + GenericContainer(DockerImageName.parse(REDIS_IMAGE)) + .withExposedPorts(6379) + companion object { private const val MYSQL_IMAGE = "mysql:8.0.41" + private const val REDIS_IMAGE = "redis:7.2.7" } } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt new file mode 100644 index 00000000..c807ce48 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt @@ -0,0 +1,60 @@ +package com.weeth.domain.account.application.usecase.command + +import com.weeth.domain.account.application.dto.request.AccountSaveRequest +import com.weeth.domain.account.application.exception.AccountExistsException +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class ManageAccountUseCaseTest : + DescribeSpec({ + val accountRepository = mockk(relaxed = true) + val cardinalReader = mockk(relaxed = true) + val clubReader = mockk(relaxed = true) + val clubPermissionPolicy = mockk(relaxed = true) + val useCase = ManageAccountUseCase(accountRepository, cardinalReader, clubReader, clubPermissionPolicy) + + val clubId = 1L + val userId = 100L + val club = ClubTestFixture.createClub() + + beforeTest { + clearMocks(accountRepository, cardinalReader, clubReader, clubPermissionPolicy) + every { clubReader.getClubById(clubId) } returns club + } + + describe("save") { + context("이미 존재하는 기수로 저장 시") { + it("AccountExistsException을 던진다") { + val request = AccountSaveRequest("설명", 100_000, 40) + every { accountRepository.existsByClubIdAndCardinal(clubId, 40) } returns true + + shouldThrow { useCase.save(clubId, request, userId) } + } + } + + context("정상 저장 시") { + it("기수 존재를 보장하고 account를 저장한다") { + val request = AccountSaveRequest("설명", 100_000, 40) + every { accountRepository.existsByClubIdAndCardinal(clubId, 40) } returns false + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 40) } returns + CardinalTestFixture.createCardinal(cardinalNumber = 40) + every { accountRepository.save(any()) } answers { firstArg() } + + useCase.save(clubId, request, userId) + + verify(exactly = 1) { clubPermissionPolicy.requireAdmin(clubId, userId) } + verify(exactly = 1) { accountRepository.save(any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt new file mode 100644 index 00000000..9dd2fdb0 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt @@ -0,0 +1,228 @@ +package com.weeth.domain.account.application.usecase.command + +import com.weeth.domain.account.application.dto.request.ReceiptSaveRequest +import com.weeth.domain.account.application.dto.request.ReceiptUpdateRequest +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.exception.ReceiptAccountMismatchException +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.repository.ReceiptRepository +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.account.fixture.AccountTestFixture +import com.weeth.domain.account.fixture.ReceiptTestFixture +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDate +import java.util.Optional + +class ManageReceiptUseCaseTest : + DescribeSpec({ + val receiptRepository = mockk(relaxUnitFun = true) + val accountRepository = mockk() + val fileReader = mockk() + val fileRepository = mockk(relaxed = true) + val cardinalReader = mockk(relaxed = true) + val clubPermissionPolicy = mockk(relaxed = true) + val fileMapper = mockk() + val useCase = + ManageReceiptUseCase( + receiptRepository, + accountRepository, + fileReader, + fileRepository, + cardinalReader, + clubPermissionPolicy, + fileMapper, + ) + + val userId = 10L + + beforeTest { + clearMocks( + receiptRepository, + accountRepository, + fileReader, + fileRepository, + cardinalReader, + clubPermissionPolicy, + fileMapper, + ) + } + + fun stubExistingCardinal( + clubId: Long, + cardinalNumber: Int, + ) { + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, cardinalNumber) } returns + CardinalTestFixture.createCardinal(cardinalNumber = cardinalNumber) + } + + describe("save") { + context("파일이 있는 경우") { + it("영수증 저장 후 fileRepository.saveAll이 호출된다") { + val account = AccountTestFixture.createAccount(cardinal = 40) + val clubId = account.club.id + val savedReceipt = ReceiptTestFixture.createReceipt(id = 10L, amount = 5_000, account = account) + val files = listOf(mockk()) + val request = + ReceiptSaveRequest( + "간식비", + "편의점", + 5_000, + LocalDate.of(2024, 9, 1), + 40, + listOf(FileSaveRequest("receipt.png", "TEMP/2024-09/receipt.png", 200L, "image/png")), + ) + + stubExistingCardinal(clubId, 40) + every { accountRepository.findByClubIdAndCardinal(clubId, 40) } returns account + every { receiptRepository.save(any()) } returns savedReceipt + every { fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, savedReceipt.id) } returns files + + useCase.save(clubId, userId, request) + + verify(exactly = 1) { receiptRepository.save(any()) } + verify(exactly = 1) { fileRepository.saveAll(files) } + } + } + + context("파일이 없는 경우") { + it("fileRepository.saveAll은 빈 리스트로 호출된다") { + val account = AccountTestFixture.createAccount(cardinal = 40) + val clubId = account.club.id + val savedReceipt = ReceiptTestFixture.createReceipt(id = 11L, amount = 3_000, account = account) + val request = ReceiptSaveRequest("교통비", "지하철", 3_000, LocalDate.of(2024, 9, 2), 40, emptyList()) + + stubExistingCardinal(clubId, 40) + every { accountRepository.findByClubIdAndCardinal(clubId, 40) } returns account + every { receiptRepository.save(any()) } returns savedReceipt + every { fileMapper.toFileList(emptyList(), FileOwnerType.RECEIPT, savedReceipt.id) } returns + emptyList() + + useCase.save(clubId, userId, request) + + verify(exactly = 1) { receiptRepository.save(any()) } + verify(exactly = 1) { fileRepository.saveAll(emptyList()) } + } + } + + context("존재하지 않는 기수로 저장 시") { + it("AccountNotFoundException을 던진다") { + val request = ReceiptSaveRequest("간식비", "편의점", 5_000, LocalDate.of(2024, 9, 1), 99, null) + val clubId = 1L + + stubExistingCardinal(clubId, 99) + every { accountRepository.findByClubIdAndCardinal(clubId, 99) } returns null + + shouldThrow { useCase.save(clubId, userId, request) } + } + } + } + + describe("update") { + it("업데이트 파일이 있으면 기존 파일을 삭제 후 새 파일을 저장한다") { + val receiptId = 10L + val account = AccountTestFixture.createAccount(cardinal = 40) + val clubId = account.club.id + val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = account) + account.spend(Money.of(receipt.amount)) + val request = + ReceiptUpdateRequest( + "desc", + "source", + 2_000, + LocalDate.of(2026, 1, 1), + 40, + listOf(FileSaveRequest("new.png", "TEMP/2026-02/new.png", 100L, "image/png")), + ) + val oldFiles = listOf(mockk()) + val newFiles = listOf(mockk()) + + stubExistingCardinal(clubId, request.cardinal) + every { accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) } returns account + every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) + every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles + every { fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receiptId) } returns newFiles + + useCase.update(clubId, userId, receiptId, request) + + verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } + verify(exactly = 1) { fileRepository.saveAll(newFiles) } + } + + it("다른 기수의 장부에 속한 영수증을 수정하면 ReceiptAccountMismatchException을 던진다") { + val receiptId = 20L + val accountA = AccountTestFixture.createAccount(id = 1L, cardinal = 40) + val clubId = accountA.club.id + val accountB = AccountTestFixture.createAccount(id = 2L, cardinal = 41) + val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = accountB) + val request = ReceiptUpdateRequest("desc", "source", 2_000, LocalDate.of(2026, 1, 1), 40, null) + + stubExistingCardinal(clubId, request.cardinal) + every { accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) } returns accountA + every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) + + shouldThrow { useCase.update(clubId, userId, receiptId, request) } + } + + it("빈 리스트로 업데이트 시 기존 파일을 모두 삭제한다") { + val receiptId = 11L + val account = AccountTestFixture.createAccount(cardinal = 40) + val clubId = account.club.id + val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = account) + account.spend(Money.of(receipt.amount)) + val request = + ReceiptUpdateRequest( + "desc", + "source", + 2_000, + LocalDate.of(2026, 1, 1), + 40, + emptyList(), + ) + val oldFiles = listOf(mockk()) + + stubExistingCardinal(clubId, request.cardinal) + every { accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) } returns account + every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) + every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles + every { fileMapper.toFileList(emptyList(), FileOwnerType.RECEIPT, receiptId) } returns emptyList() + + useCase.update(clubId, userId, receiptId, request) + + verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } + verify(exactly = 1) { fileRepository.saveAll(emptyList()) } + } + } + + describe("delete") { + it("관련 파일 삭제 후 cancelSpend가 호출되고 영수증이 삭제된다") { + val receiptId = 5L + val account = AccountTestFixture.createAccount(currentAmount = 100_000) + val clubId = account.club.id + val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 10_000, account = account) + account.spend(Money.of(receipt.amount)) + val files = listOf(mockk()) + + every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) + every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns files + + useCase.delete(clubId, userId, receiptId) + + verify(exactly = 1) { fileRepository.deleteAll(files) } + verify(exactly = 1) { receiptRepository.delete(receipt) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt new file mode 100644 index 00000000..0c5cebab --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt @@ -0,0 +1,103 @@ +package com.weeth.domain.account.application.usecase.query + +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.mapper.AccountMapper +import com.weeth.domain.account.application.mapper.ReceiptMapper +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.repository.ReceiptRepository +import com.weeth.domain.account.fixture.AccountTestFixture +import com.weeth.domain.account.fixture.ReceiptTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GetAccountQueryServiceTest : + DescribeSpec({ + val accountRepository = mockk() + val receiptRepository = mockk() + val fileReader = mockk() + val clubMemberPolicy = mockk(relaxed = true) + val accountMapper = mockk() + val receiptMapper = mockk() + val fileMapper = mockk() + val queryService = + GetAccountQueryService( + accountRepository, + receiptRepository, + fileReader, + clubMemberPolicy, + accountMapper, + receiptMapper, + fileMapper, + ) + + val clubId = 1L + val userId = 7L + + beforeTest { + clearMocks( + accountRepository, + receiptRepository, + fileReader, + clubMemberPolicy, + accountMapper, + receiptMapper, + fileMapper, + ) + } + + describe("findByCardinal") { + context("존재하는 기수 조회 시") { + it("영수증이 있으면 fileReader.findAll을 receiptIds 배치로 1회 호출한다") { + val account = AccountTestFixture.createAccount(cardinal = 40) + val receipt1 = ReceiptTestFixture.createReceipt(id = 1L, account = account) + val receipt2 = ReceiptTestFixture.createReceipt(id = 2L, account = account) + val accountResponse = mockk() + + every { accountRepository.findByClubIdAndCardinal(clubId, 40) } returns account + every { receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(account.id) } returns + listOf(receipt1, receipt2) + every { fileReader.findAll(FileOwnerType.RECEIPT, listOf(1L, 2L), null) } returns emptyList() + every { fileMapper.toFileResponse(any()) } returns mockk() + every { receiptMapper.toResponses(any(), any()) } returns emptyList() + every { accountMapper.toResponse(account, emptyList()) } returns accountResponse + + val result = queryService.findByCardinal(clubId, userId, 40) + + result shouldBe accountResponse + verify(exactly = 1) { fileReader.findAll(FileOwnerType.RECEIPT, listOf(1L, 2L), null) } + } + + it("영수증이 없으면 fileReader.findAll을 빈 리스트로 호출한다") { + val account = AccountTestFixture.createAccount(cardinal = 40) + val accountResponse = mockk() + + every { accountRepository.findByClubIdAndCardinal(clubId, 40) } returns account + every { receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(account.id) } returns emptyList() + every { fileReader.findAll(FileOwnerType.RECEIPT, emptyList(), null) } returns emptyList() + every { receiptMapper.toResponses(emptyList(), emptyMap()) } returns emptyList() + every { accountMapper.toResponse(account, emptyList()) } returns accountResponse + + queryService.findByCardinal(clubId, userId, 40) + + verify(exactly = 1) { fileReader.findAll(FileOwnerType.RECEIPT, emptyList(), null) } + } + } + + context("존재하지 않는 기수 조회 시") { + it("AccountNotFoundException을 던진다") { + every { accountRepository.findByClubIdAndCardinal(clubId, 99) } returns null + + shouldThrow { queryService.findByCardinal(clubId, userId, 99) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt b/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt new file mode 100644 index 00000000..797c7444 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt @@ -0,0 +1,55 @@ +package com.weeth.domain.account.domain.entity + +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.account.fixture.AccountTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class AccountTest : + StringSpec({ + "spend은 currentAmount를 Money 금액만큼 감소시킨다" { + val account = AccountTestFixture.createAccount(currentAmount = 100_000) + + account.spend(Money.of(10_000)) + + account.currentAmount shouldBe 90_000 + } + + "cancelSpend은 currentAmount를 Money 금액만큼 복원한다" { + val account = AccountTestFixture.createAccount(currentAmount = 90_000) + + account.cancelSpend(Money.of(10_000)) + + account.currentAmount shouldBe 100_000 + } + + "adjustSpend는 기존 금액을 취소하고 새 금액을 차감한다" { + val account = AccountTestFixture.createAccount(totalAmount = 100_000, currentAmount = 90_000) + + account.adjustSpend(Money.of(10_000), Money.of(20_000)) + + account.currentAmount shouldBe 80_000 + } + + "spend 시 잔액이 부족하면 IllegalStateException을 던진다" { + val account = AccountTestFixture.createAccount(currentAmount = 5_000) + + shouldThrow { account.spend(Money.of(10_000)) } + } + + "cancelSpend 시 총액을 초과하면 IllegalStateException을 던진다" { + val account = AccountTestFixture.createAccount(totalAmount = 100_000, currentAmount = 100_000) + + shouldThrow { account.cancelSpend(Money.of(1)) } + } + + "create는 currentAmount를 totalAmount와 동일하게 초기화한다" { + val account = Account.create(ClubTestFixture.createClub(), "2학기 회비", 200_000, 41) + + account.currentAmount shouldBe 200_000 + account.totalAmount shouldBe 200_000 + account.cardinal shouldBe 41 + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/domain/entity/ReceiptTest.kt b/src/test/kotlin/com/weeth/domain/account/domain/entity/ReceiptTest.kt new file mode 100644 index 00000000..ac753511 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/domain/entity/ReceiptTest.kt @@ -0,0 +1,35 @@ +package com.weeth.domain.account.domain.entity + +import com.weeth.domain.account.fixture.ReceiptTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.time.LocalDate + +class ReceiptTest : + StringSpec({ + "update는 영수증 필드를 갱신한다" { + val receipt = + ReceiptTestFixture.createReceipt( + description = "기존 설명", + source = "기존 출처", + amount = 5_000, + date = LocalDate.of(2024, 1, 1), + ) + + receipt.update("새로운 설명", "새 출처", 20_000, LocalDate.of(2025, 6, 1)) + + receipt.description shouldBe "새로운 설명" + receipt.source shouldBe "새 출처" + receipt.amount shouldBe 20_000 + receipt.date shouldBe LocalDate.of(2025, 6, 1) + } + + "update 시 amount가 0 이하면 IllegalArgumentException을 던진다" { + val receipt = ReceiptTestFixture.createReceipt(amount = 5_000) + + shouldThrow { + receipt.update("설명", "출처", 0, LocalDate.of(2025, 6, 1)) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt b/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt new file mode 100644 index 00000000..e0157e61 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.account.fixture + +import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.fixture.ClubTestFixture + +object AccountTestFixture { + fun createAccount( + id: Long = 1L, + club: Club = ClubTestFixture.createClub(), + description: String = "2024년 2학기 회비", + totalAmount: Int = 100_000, + currentAmount: Int = 100_000, + cardinal: Int = 40, + ): Account = + Account( + club = club, + id = id, + description = description, + totalAmount = totalAmount, + currentAmount = currentAmount, + cardinal = cardinal, + ) +} diff --git a/src/test/kotlin/com/weeth/domain/account/fixture/ReceiptTestFixture.kt b/src/test/kotlin/com/weeth/domain/account/fixture/ReceiptTestFixture.kt new file mode 100644 index 00000000..b02c7535 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/fixture/ReceiptTestFixture.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.account.fixture + +import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.account.domain.entity.Receipt +import java.time.LocalDate + +object ReceiptTestFixture { + fun createReceipt( + id: Long = 1L, + description: String = "간식비", + source: String = "편의점", + amount: Int = 10_000, + date: LocalDate = LocalDate.of(2024, 9, 1), + account: Account = AccountTestFixture.createAccount(), + ): Receipt = + Receipt( + id = id, + description = description, + source = source, + amount = amount, + date = date, + account = account, + ) +} diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt index 2c38f017..85cd1015 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt @@ -1,142 +1,150 @@ package com.weeth.domain.attendance.application.mapper import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUserWithAttendances -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAdminUserWithAttendances +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAdminUser import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createOneDayMeeting import com.weeth.domain.attendance.fixture.AttendanceTestFixture.enrichUserProfile import com.weeth.domain.attendance.fixture.AttendanceTestFixture.setAttendanceId -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.setUserAttendanceStats -import com.weeth.domain.user.domain.entity.enums.Position +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.session.fixture.SessionTestFixture.createOneDaySession import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe -import org.mapstruct.factory.Mappers import java.time.LocalDate class AttendanceMapperTest : DescribeSpec({ + val mapper = AttendanceMapper() - val attendanceMapper = Mappers.getMapper(AttendanceMapper::class.java) - - describe("toMainDto") { - it("사용자 + 당일 출석 객체를 Main DTO로 매핑한다") { + describe("toSummaryResponse") { + it("사용자 + 당일 출석 객체를 MainResponse로 매핑한다") { val today = LocalDate.now() - val meeting = createOneDayMeeting(today, 1, 1111, "Today") - val user = createActiveUserWithAttendances("이지훈", listOf(meeting)) - val attendance = user.attendances[0] + val session = createOneDaySession(today, 1, 1111, "Today") + val member = + ClubMemberTestFixture.createActiveMember( + club = session.club, + user = createActiveUser("이지훈"), + ) + val attendance = createAttendance(session, member) - val main = attendanceMapper.toMainDto(user, attendance) + val main = mapper.toSummaryResponse(member, attendance) main.shouldNotBeNull() - main.title() shouldBe "Today" - main.status() shouldBe attendance.status - main.start() shouldBe meeting.start - main.end() shouldBe meeting.end - main.location() shouldBe meeting.location + main.title shouldBe session.title + main.status shouldBe attendance.status + main.start shouldBe session.start + main.end shouldBe session.end + main.location shouldBe session.location } - it("todayAttendance가 null이면 필드는 null로 매핑") { - val user = createActiveUser("이지훈") + it("attendance가 null이면 필드는 null로 매핑") { + val member = ClubMemberTestFixture.createActiveMember(user = createActiveUser("이지훈")) - val main = attendanceMapper.toMainDto(user, null) + val main = mapper.toSummaryResponse(member, null) main.shouldNotBeNull() - main.title().shouldBeNull() - main.start().shouldBeNull() - main.end().shouldBeNull() - main.location().shouldBeNull() + main.title.shouldBeNull() + main.start.shouldBeNull() + main.end.shouldBeNull() + main.location.shouldBeNull() } it("일반 유저는 출석 코드가 null로 매핑된다") { val today = LocalDate.now() - val meeting = createOneDayMeeting(today, 1, 1234, "Today") - val user = createActiveUserWithAttendances("일반유저", listOf(meeting)) - val attendance = user.attendances[0] + val session = createOneDaySession(today, 1, 1234, "Today") + val member = + ClubMemberTestFixture.createActiveMember( + club = session.club, + user = createActiveUser("일반유저"), + ) + val attendance = createAttendance(session, member) + + val main = mapper.toSummaryResponse(member, attendance) + + main.shouldNotBeNull() + main.title shouldBe session.title + main.status shouldBe attendance.status + } + + it("ADMIN 유저는 출석 코드가 포함된다") { + val today = LocalDate.now() + val expectedCode = 1234 + val session = createOneDaySession(today, 1, expectedCode, "Today") + val adminUser = createAdminUser("관리자") + val member = ClubMemberTestFixture.createAdminMember(club = session.club, user = adminUser) + val attendance = createAttendance(session, member) - val main = attendanceMapper.toMainDto(user, attendance) + val main = mapper.toSummaryResponse(member, attendance) main.shouldNotBeNull() - main.code().shouldBeNull() - main.title() shouldBe "Today" - main.status() shouldBe attendance.status + main.title shouldBe session.title + main.start shouldBe session.start + main.end shouldBe session.end + main.location shouldBe session.location } } - describe("toResponseDto") { - it("단일 출석을 Response DTO로 매핑한다") { - val meeting = createOneDayMeeting(LocalDate.now().minusDays(1), 1, 2222, "D-1") - val user = createActiveUser("사용자A") - val attendance = createAttendance(meeting, user) + describe("toResponse") { + it("단일 출석을 AttendanceResponse로 매핑한다") { + val session = createOneDaySession(LocalDate.now().minusDays(1), 1, 2222, "D-1") + val member = + ClubMemberTestFixture.createActiveMember( + club = session.club, + user = createActiveUser("사용자A"), + ) + val attendance = createAttendance(session, member) - val response = attendanceMapper.toResponseDto(attendance) + val response = mapper.toResponse(attendance) response.shouldNotBeNull() - response.title() shouldBe "D-1" - response.start() shouldBe meeting.start - response.end() shouldBe meeting.end - response.location() shouldBe meeting.location + response.title shouldBe session.title + response.start shouldBe session.start + response.end shouldBe session.end + response.location shouldBe session.location } } - describe("toDetailDto") { - it("사용자 + Response 리스트를 Detail DTO로 매핑(total = attend + absence)") { + describe("toDetailResponse") { + it("사용자 + Response 리스트를 DetailResponse로 매핑(total = attend + absence)") { val base = LocalDate.now() - val m1 = createOneDayMeeting(base.minusDays(2), 1, 1000, "D-2") - val m2 = createOneDayMeeting(base.minusDays(1), 1, 1001, "D-1") - val user = createActiveUser("이지훈") - setUserAttendanceStats(user, 3, 2) + val m1 = createOneDaySession(base.minusDays(2), 1, 1000, "D-2") + val m2 = createOneDaySession(base.minusDays(1), 1, 1001, "D-1", club = m1.club) + val member = ClubMemberTestFixture.createActiveMember(club = m1.club, user = createActiveUser("이지훈")) + repeat(3) { member.attend() } + repeat(2) { member.absent() } - val a1 = createAttendance(m1, user) - val a2 = createAttendance(m2, user) + val a1 = createAttendance(m1, member) + val a2 = createAttendance(m2, member) - val r1 = attendanceMapper.toResponseDto(a1) - val r2 = attendanceMapper.toResponseDto(a2) + val r1 = mapper.toResponse(a1) + val r2 = mapper.toResponse(a2) - val detail = attendanceMapper.toDetailDto(user, listOf(r1, r2)) + val detail = mapper.toDetailResponse(member, listOf(r1, r2)) detail.shouldNotBeNull() - detail.attendances() shouldBe listOf(r1, r2) - detail.total() shouldBe 5 + detail.attendances shouldBe listOf(r1, r2) + detail.total shouldBe member.attendanceStats.attendanceCount + member.attendanceStats.absenceCount } } - describe("toAttendanceInfoDto") { - it("Attendance를 Info DTO로 매핑") { - val meeting = createOneDayMeeting(LocalDate.now(), 1, 3333, "Info") + describe("toInfoResponse") { + it("Attendance를 InfoResponse로 매핑") { + val session = createOneDaySession(LocalDate.now(), 1, 3333, "Info") val user = createActiveUser("유저B") - enrichUserProfile(user, Position.BE, "컴퓨터공학과", "20201234") + enrichUserProfile(user, "컴퓨터공학과", "20201234") + val member = ClubMemberTestFixture.createActiveMember(club = session.club, user = user) - val attendance = createAttendance(meeting, user) + val attendance = createAttendance(session, member) setAttendanceId(attendance, 10L) - val info = attendanceMapper.toAttendanceInfoDto(attendance) + val info = mapper.toInfoResponse(attendance) info.shouldNotBeNull() - info.id() shouldBe 10L - info.status() shouldBe attendance.status - info.name() shouldBe "유저B" - } - } - - describe("toAdminResponse") { - it("ADMIN 유저는 출석 코드가 포함된다") { - val today = LocalDate.now() - val expectedCode = 1234 - val meeting = createOneDayMeeting(today, 1, expectedCode, "Today") - val adminUser = createAdminUserWithAttendances("관리자", listOf(meeting)) - val attendance = adminUser.attendances[0] - - val main = attendanceMapper.toAdminResponse(adminUser, attendance) - - main.shouldNotBeNull() - main.code() shouldBe expectedCode - main.title() shouldBe "Today" - main.start() shouldBe meeting.start - main.end() shouldBe meeting.end - main.location() shouldBe meeting.location + info.id shouldBe attendance.id + info.status shouldBe attendance.status + info.name shouldBe user.name + info.department shouldBe user.department } } }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImplTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImplTest.kt deleted file mode 100644 index 6f41a783..00000000 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImplTest.kt +++ /dev/null @@ -1,287 +0,0 @@ -package com.weeth.domain.attendance.application.usecase - -import com.weeth.domain.attendance.application.dto.AttendanceDTO -import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException -import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException -import com.weeth.domain.attendance.application.mapper.AttendanceMapper -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.entity.enums.Status -import com.weeth.domain.attendance.domain.service.AttendanceGetService -import com.weeth.domain.attendance.domain.service.AttendanceUpdateService -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUserWithAttendances -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createInProgressMeeting -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createOneDayMeeting -import com.weeth.domain.schedule.application.exception.MeetingNotFoundException -import com.weeth.domain.schedule.domain.entity.Meeting -import com.weeth.domain.schedule.domain.service.MeetingGetService -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.service.UserCardinalGetService -import com.weeth.domain.user.domain.service.UserGetService -import io.kotest.assertions.throwables.shouldNotThrowAny -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import java.time.LocalDate -import java.time.LocalDateTime - -class AttendanceUseCaseImplTest : - DescribeSpec({ - - val userId = 10L - val userGetService = mockk() - val userCardinalGetService = mockk() - val attendanceGetService = mockk() - val attendanceUpdateService = mockk(relaxUnitFun = true) - val attendanceMapper = mockk() - val meetingGetService = mockk() - - val attendanceUseCase = - AttendanceUseCaseImpl( - userGetService, - userCardinalGetService, - attendanceGetService, - attendanceUpdateService, - attendanceMapper, - meetingGetService, - ) - - describe("find") { - it("여러 날짜의 출석 목록 중 시작/종료 날짜가 모두 오늘인 출석정보를 선택") { - val today = LocalDate.now() - - val meetingYesterday = createOneDayMeeting(today.minusDays(1), 1, 1111, "Yesterday") - val meetingToday = createOneDayMeeting(today, 1, 2222, "Today") - val meetingTomorrow = createOneDayMeeting(today.plusDays(1), 1, 3333, "Tomorrow") - - val user = - createActiveUserWithAttendances( - "이지훈", - listOf(meetingYesterday, meetingToday, meetingTomorrow), - ) - - val expectedTodayAttendance = - user.attendances.first { - it.meeting.title == "Today" - } - - val mapped = mockk() - - every { userGetService.find(userId) } returns user - every { attendanceMapper.toMainDto(eq(user), eq(expectedTodayAttendance)) } returns mapped - - val actual = attendanceUseCase.find(userId) - - actual shouldBe mapped - verify { attendanceMapper.toMainDto(eq(user), eq(expectedTodayAttendance)) } - } - - it("시작/종료 날짜가 모두 오늘인 출석이 없다면 mapper.toMainDto(user, null)을 호출") { - val today = LocalDate.now() - - val yesterdayMeeting = createOneDayMeeting(today.minusDays(1), 1, 1111, "Yesterday") - val tomorrowMeeting = createOneDayMeeting(today.plusDays(1), 1, 3333, "Tomorrow") - - val user = - createActiveUserWithAttendances( - "이지훈", - listOf(yesterdayMeeting, tomorrowMeeting), - ) - - val mapped = mockk() - every { userGetService.find(userId) } returns user - every { attendanceMapper.toMainDto(user, null) } returns mapped - - val actual = attendanceUseCase.find(userId) - - actual shouldBe mapped - verify { attendanceMapper.toMainDto(user, null) } - } - } - - describe("checkIn") { - context("10분 전부터 출석이 가능한지 확인") { - it("5분 뒤 시작 회의에 출석 성공") { - val now = LocalDateTime.now() - val meeting = - Meeting - .builder() - .start(now.plusMinutes(5)) - .end(now.plusHours(2)) - .code(1234) - .title("Today") - .cardinal(1) - .build() - - val user = createActiveUserWithAttendances("이지훈", listOf(meeting)) - - every { userGetService.find(userId) } returns user - - shouldNotThrowAny { - attendanceUseCase.checkIn(userId, 1234) - } - verify(exactly = 1) { attendanceUpdateService.attend(any()) } - } - - it("11분 전에 출석시 오류 발생") { - val now = LocalDateTime.now() - val meeting = - Meeting - .builder() - .start(now.plusMinutes(11)) - .end(now.plusHours(2)) - .code(1234) - .title("Today") - .cardinal(1) - .build() - - val user = createActiveUserWithAttendances("이지훈", listOf(meeting)) - - every { userGetService.find(userId) } returns user - - shouldThrow { - attendanceUseCase.checkIn(userId, 1234) - } - } - } - - context("진행 중 정기모임이고 코드 일치하며 상태가 ATTEND가 아닐 때") { - it("출석 처리된다") { - val user = mockk() - val inProgressMeeting = createInProgressMeeting(1, 1234, "InProgress") - val attendance = mockk() - every { attendance.meeting } returns inProgressMeeting - every { attendance.isWrong(1234) } returns false - every { attendance.status } returns Status.PENDING - - every { userGetService.find(userId) } returns user - every { user.attendances } returns listOf(attendance) - - attendanceUseCase.checkIn(userId, 1234) - - verify { attendanceUpdateService.attend(attendance) } - } - } - - context("진행 중 정기모임이 없을 때") { - it("AttendanceNotFoundException") { - val user = mockk() - every { userGetService.find(userId) } returns user - every { user.attendances } returns listOf() - - shouldThrow { - attendanceUseCase.checkIn(userId, 1234) - } - } - } - - context("코드 불일치 시") { - it("AttendanceCodeMismatchException") { - val user = mockk() - val inProgressMeeting = createInProgressMeeting(1, 1234, "InProgress") - - val attendance = mockk() - every { attendance.meeting } returns inProgressMeeting - every { attendance.isWrong(9999) } returns true - - every { userGetService.find(userId) } returns user - every { user.attendances } returns listOf(attendance) - - shouldThrow { - attendanceUseCase.checkIn(userId, 9999) - } - } - } - - context("이미 ATTEND일 때") { - it("추가 처리 없이 종료") { - val user = mockk() - val inProgressMeeting = createInProgressMeeting(1, 1234, "InProgress") - - val attendance = mockk() - every { attendance.meeting } returns inProgressMeeting - every { attendance.isWrong(1234) } returns false - every { attendance.status } returns Status.ATTEND - - every { userGetService.find(userId) } returns user - every { user.attendances } returns listOf(attendance) - - attendanceUseCase.checkIn(userId, 1234) - - verify(exactly = 0) { attendanceUpdateService.attend(attendance) } - } - } - } - - describe("findAllDetailsByCurrentCardinal") { - it("현재 기수만 필터링·정렬하여 Detail 매핑") { - val today = LocalDate.now() - val meetingDayMinus1 = createOneDayMeeting(today.minusDays(1), 1, 1111, "D-1") - val meetingToday = createOneDayMeeting(today, 1, 2222, "D-Day") - val user = createActiveUserWithAttendances("이지훈", listOf(meetingDayMinus1, meetingToday)) - - val userAttendances = user.attendances - val attendanceFirst = userAttendances[0] - val attendanceSecond = userAttendances[1] - - every { userGetService.find(userId) } returns user - val currentCardinal = mockk() - every { currentCardinal.cardinalNumber } returns 1 - every { userCardinalGetService.getCurrentCardinal(user) } returns currentCardinal - - val responseFirst = mockk() - val responseSecond = mockk() - every { attendanceMapper.toResponseDto(attendanceFirst) } returns responseFirst - every { attendanceMapper.toResponseDto(attendanceSecond) } returns responseSecond - - val expectedDetail = mockk() - every { attendanceMapper.toDetailDto(eq(user), any()) } returns expectedDetail - - val actualDetail = attendanceUseCase.findAllDetailsByCurrentCardinal(userId) - - actualDetail shouldBe expectedDetail - verify { - attendanceMapper.toDetailDto( - eq(user), - match { it.size == 2 }, - ) - } - } - } - - describe("close") { - it("당일 정기모임을 찾아 close") { - val now = LocalDate.now() - val targetMeeting = createOneDayMeeting(now, 1, 1111, "Today") - val otherMeeting = createOneDayMeeting(now.minusDays(1), 1, 9999, "Yesterday") - - val attendance1 = mockk() - val attendance2 = mockk() - - every { meetingGetService.find(1) } returns listOf(targetMeeting, otherMeeting) - every { attendanceGetService.findAllByMeeting(targetMeeting) } returns listOf(attendance1, attendance2) - - attendanceUseCase.close(now, 1) - - verify { - attendanceUpdateService.close( - match { it.size == 2 && it.containsAll(listOf(attendance1, attendance2)) }, - ) - } - } - - it("당일 정기모임이 없으면 MeetingNotFoundException") { - val now = LocalDate.now() - val otherDayMeeting = createOneDayMeeting(now.minusDays(1), 1, 9999, "Yesterday") - - every { meetingGetService.find(1) } returns listOf(otherDayMeeting) - - shouldThrow { - attendanceUseCase.close(now, 1) - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt new file mode 100644 index 00000000..cc9eb595 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt @@ -0,0 +1,88 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.dto.response.QrTokenResponse +import com.weeth.domain.attendance.application.event.AttendanceOpenEvent +import com.weeth.domain.attendance.application.event.AttendanceSseEvent +import com.weeth.domain.attendance.application.mapper.AttendanceMapper +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.port.SseBroadcastPort +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.springframework.transaction.PlatformTransactionManager +import java.time.LocalDateTime + +class GenerateQrTokenUseCaseTest : + DescribeSpec({ + val sessionReader = mockk() + val qrAttendancePort = mockk() + val attendanceMapper = mockk() + val clubPermissionPolicy = mockk(relaxed = true) + val ssePort = mockk(relaxed = true) + val transactionManager = mockk(relaxed = true) + + val useCase = + GenerateQrTokenUseCase( + sessionReader, + qrAttendancePort, + attendanceMapper, + clubPermissionPolicy, + ssePort, + transactionManager, + ) + + beforeTest { + clearMocks(sessionReader, qrAttendancePort, attendanceMapper, clubPermissionPolicy, ssePort) + } + + describe("execute") { + val sessionId = 1L + val code = 123456 + + context("유효한 sessionId") { + it("Redis에 코드를 저장하고 QrTokenResponse를 반환한다") { + val session = SessionTestFixture.createSession(id = sessionId, code = code) + val expectedResponse = + QrTokenResponse( + sessionId = sessionId, + code = code, + expiredAt = LocalDateTime.now().plusSeconds(QrAttendancePort.TTL_SECONDS), + ) + + every { sessionReader.getById(sessionId) } returns session + every { qrAttendancePort.store(sessionId, code) } just Runs + every { attendanceMapper.toQrTokenResponse(eq(session), any()) } returns expectedResponse + + val result = useCase.execute(sessionId, 10L, 20L) + + result shouldBe expectedResponse + verify(exactly = 1) { clubPermissionPolicy.requireAdmin(10L, 20L) } + verify(exactly = 1) { qrAttendancePort.store(sessionId, code) } + verify(exactly = 1) { + ssePort.broadcast(10L, AttendanceSseEvent.QR_OPEN, any()) + } + } + } + + context("존재하지 않는 sessionId") { + it("SessionNotFoundException을 던진다") { + every { sessionReader.getById(sessionId) } throws SessionNotFoundException() + + shouldThrow { useCase.execute(sessionId, 10L, 20L) } + + verify(exactly = 0) { qrAttendancePort.store(any(), any()) } + verify(exactly = 0) { ssePort.broadcast(any(), any(), any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt new file mode 100644 index 00000000..9cd46bb5 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt @@ -0,0 +1,149 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest +import com.weeth.domain.attendance.application.exception.AlreadyAttendedException +import com.weeth.domain.attendance.application.exception.AttendanceAlreadyClosedException +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.setAttendanceId +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk + +class ManageAttendanceUseCaseTest : + DescribeSpec({ + val clubMemberPolicy = mockk() + val clubPermissionPolicy = mockk() + val sessionReader = mockk() + val attendanceRepository = mockk() + val qrAttendancePort = mockk() + + val useCase = + ManageAttendanceUseCase( + clubMemberPolicy, + clubPermissionPolicy, + sessionReader, + attendanceRepository, + qrAttendancePort, + ) + + beforeTest { + clearMocks(clubMemberPolicy, clubPermissionPolicy, sessionReader, attendanceRepository, qrAttendancePort) + } + + describe("checkIn") { + lateinit var clubMember: ClubMember + lateinit var session: Session + + beforeTest { + clubMember = ClubMemberTestFixture.createActiveMember() + session = + SessionTestFixture.createInProgressSession( + cardinal = 1, + code = 123456, + title = "Test Session", + club = clubMember.club, + ) + } + + it("정상 체크인 시 출석 상태와 멤버 통계를 갱신한다") { + val attendance = createAttendance(session, clubMember) + every { qrAttendancePort.getCode(session.id) } returns session.code + every { sessionReader.getById(session.id) } returns session + every { clubMemberPolicy.getActiveMember(clubMember.club.id, clubMember.user.id) } returns clubMember + every { attendanceRepository.findBySessionAndClubMemberWithLock(session, clubMember) } returns + attendance + + useCase.checkIn(clubMember.club.id, clubMember.user.id, session.id, session.code) + + attendance.status shouldBe AttendanceStatus.ATTEND + clubMember.attendanceStats.attendanceCount shouldBe 1 + } + + it("이미 출석 처리된 경우 예외를 던진다") { + val attendedAttendance = createAttendance(session, clubMember).also { it.attend() } + every { qrAttendancePort.getCode(session.id) } returns session.code + every { sessionReader.getById(session.id) } returns session + every { clubMemberPolicy.getActiveMember(clubMember.club.id, clubMember.user.id) } returns clubMember + every { attendanceRepository.findBySessionAndClubMemberWithLock(session, clubMember) } returns + attendedAttendance + + shouldThrow { + useCase.checkIn(clubMember.club.id, clubMember.user.id, session.id, session.code) + } + } + + it("세션이 이미 마감된 경우(ABSENT) 예외를 던진다") { + val absentAttendance = createAttendance(session, clubMember).also { it.absent() } + every { qrAttendancePort.getCode(session.id) } returns session.code + every { sessionReader.getById(session.id) } returns session + every { clubMemberPolicy.getActiveMember(clubMember.club.id, clubMember.user.id) } returns clubMember + every { attendanceRepository.findBySessionAndClubMemberWithLock(session, clubMember) } returns + absentAttendance + + shouldThrow { + useCase.checkIn(clubMember.club.id, clubMember.user.id, session.id, session.code) + } + } + + it("출석 레코드가 없으면 예외를 던진다") { + every { qrAttendancePort.getCode(session.id) } returns session.code + every { sessionReader.getById(session.id) } returns session + every { clubMemberPolicy.getActiveMember(clubMember.club.id, clubMember.user.id) } returns clubMember + every { attendanceRepository.findBySessionAndClubMemberWithLock(session, clubMember) } returns null + + shouldThrow { + useCase.checkIn(clubMember.club.id, clubMember.user.id, session.id, session.code) + } + } + } + + describe("updateStatus") { + it("관리자가 ATTEND로 변경하면 ClubMember 통계를 갱신한다") { + val admin = ClubMemberTestFixture.createAdminMember() + val member = ClubMemberTestFixture.createActiveMember(club = admin.club) + val attendance = + createAttendance(SessionTestFixture.createSession(club = admin.club), member) + .also { setAttendanceId(it, 1L) } + + every { clubPermissionPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { attendanceRepository.findAllByIdsWithLock(listOf(1L)) } returns listOf(attendance) + + useCase.updateStatus(admin.club.id, admin.user.id, listOf(UpdateAttendanceStatusRequest(1L, "ATTEND"))) + + attendance.status shouldBe AttendanceStatus.ATTEND + member.attendanceStats.attendanceCount shouldBe 1 + } + + it("관리자가 PENDING으로 되돌리면 기존 통계를 차감한다") { + val admin = ClubMemberTestFixture.createAdminMember() + val member = ClubMemberTestFixture.createActiveMember(club = admin.club) + val attendance = + createAttendance(SessionTestFixture.createSession(club = admin.club), member) + .also { setAttendanceId(it, 1L) } + attendance.attend() + member.attend() + + every { clubPermissionPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { attendanceRepository.findAllByIdsWithLock(listOf(1L)) } returns listOf(attendance) + + useCase.updateStatus(admin.club.id, admin.user.id, listOf(UpdateAttendanceStatusRequest(1L, "PENDING"))) + + attendance.status shouldBe AttendanceStatus.PENDING + member.attendanceStats.attendanceCount shouldBe 0 + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCaseTest.kt new file mode 100644 index 00000000..fe35f96c --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCaseTest.kt @@ -0,0 +1,114 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.event.AttendanceOpenEvent +import com.weeth.domain.attendance.application.event.AttendanceSseEvent +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.port.SseBroadcastPort +import com.weeth.domain.attendance.domain.port.SseSubscribePort +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.session.domain.repository.SessionReader +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.time.LocalDateTime + +class SubscribeAttendanceSseUseCaseTest : + DescribeSpec({ + val sseSubscribePort = mockk() + val sseBroadcastPort = mockk(relaxed = true) + val clubMemberPolicy = mockk() + val sessionReader = mockk() + val qrAttendancePort = mockk() + val useCase = + SubscribeAttendanceSseUseCase( + sseSubscribePort, + sseBroadcastPort, + clubMemberPolicy, + sessionReader, + qrAttendancePort, + ) + + beforeTest { clearMocks(sseSubscribePort, sseBroadcastPort, clubMemberPolicy, sessionReader, qrAttendancePort) } + + describe("execute") { + val clubId = 1L + val userId = 100L + val emitter = mockk(relaxed = true) + + beforeTest { + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns mockk() + every { sseSubscribePort.subscribe(clubId, userId) } returns emitter + } + + context("활성 QR이 없는 경우") { + it("qr-none 이벤트를 전송하고 emitter를 반환한다") { + every { sessionReader.findOpenByClubId(clubId) } returns null + + val result = useCase.execute(clubId, userId) + + result shouldBe emitter + verify( + exactly = 1, + ) { sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_NONE, null) } + verify( + exactly = 0, + ) { sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_OPEN, any()) } + } + } + + context("열린 세션이 있지만 QR이 만료된 경우") { + it("qr-none 이벤트를 전송한다") { + val session = mockk { every { id } returns 10L } + every { sessionReader.findOpenByClubId(clubId) } returns session + every { qrAttendancePort.getExpiredAt(10L) } returns null + + useCase.execute(clubId, userId) + + verify( + exactly = 1, + ) { sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_NONE, null) } + } + } + + context("활성 QR이 있는 경우") { + it("qr-open 이벤트를 전송하고 emitter를 반환한다") { + val session = mockk { every { id } returns 10L } + val expiredAt = LocalDateTime.now().plusMinutes(5) + every { sessionReader.findOpenByClubId(clubId) } returns session + every { qrAttendancePort.getExpiredAt(10L) } returns expiredAt + + val result = useCase.execute(clubId, userId) + + result shouldBe emitter + verify(exactly = 1) { + sseBroadcastPort.sendToUser( + clubId, + userId, + AttendanceSseEvent.QR_OPEN, + AttendanceOpenEvent(expiredAt), + ) + } + verify( + exactly = 0, + ) { sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_NONE, any()) } + } + } + + context("비활성 멤버이거나 클럽에 속하지 않은 경우") { + it("예외를 던지고 SSE 구독을 하지 않는다") { + every { clubMemberPolicy.getActiveMember(clubId, userId) } throws MemberNotActiveException() + + shouldThrow { useCase.execute(clubId, userId) } + + verify(exactly = 0) { sseSubscribePort.subscribe(any(), any()) } + verify(exactly = 0) { sseBroadcastPort.sendToUser(any(), any(), any(), any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt new file mode 100644 index 00000000..4340b291 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt @@ -0,0 +1,219 @@ +package com.weeth.domain.attendance.application.usecase.query + +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.application.mapper.AttendanceMapper +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDateTime + +class GetAttendanceQueryServiceTest : + DescribeSpec({ + val clubMemberPolicy = mockk() + val clubPermissionPolicy = mockk() + val clubMemberCardinalPolicy = mockk() + val sessionReader = mockk() + val attendanceRepository = mockk() + val attendanceMapper = AttendanceMapper() + + val queryService = + GetAttendanceQueryService( + clubMemberPolicy, + clubPermissionPolicy, + clubMemberCardinalPolicy, + sessionReader, + attendanceRepository, + attendanceMapper, + ) + + describe("findAttendance") { + beforeTest { + clearMocks(clubMemberPolicy, attendanceRepository) + } + + it("오늘 출석이 1개이면 해당 출석을 반환한다") { + val member = ClubMemberTestFixture.createActiveMember() + member.attend() + val session = + SessionTestFixture.createInProgressSession( + cardinal = 1, + code = 111111, + title = "오늘 모임", + club = member.club, + ) + val attendance = Attendance.create(session, member) + + every { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } returns member + every { attendanceRepository.findTodayByClubMemberId(member.id, any(), any()) } returns + listOf(attendance) + + val result = queryService.findAttendance(member.club.id, member.user.id) + + result.attendanceRate shouldBe member.attendanceStats.attendanceRate + result.title shouldBe session.title + result.status shouldBe AttendanceStatus.PENDING + verify(exactly = 1) { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } + } + + it("오늘 출석이 없으면 세션 관련 필드를 null로 반환한다") { + val member = ClubMemberTestFixture.createActiveMember() + + every { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } returns member + every { attendanceRepository.findTodayByClubMemberId(member.id, any(), any()) } returns emptyList() + + val result = queryService.findAttendance(member.club.id, member.user.id) + + result.title shouldBe null + result.status shouldBe null + result.sessionId shouldBe null + } + + it("오늘 세션이 여러 개이면 현재 시각 이후 가장 가까운 세션을 반환한다") { + val member = ClubMemberTestFixture.createActiveMember() + val now = LocalDateTime.now() + val pastSession = + SessionTestFixture.createSession( + title = "오전 세션", + start = now.minusHours(3), + end = now.minusHours(1), + club = member.club, + ) + val upcomingSession = + SessionTestFixture.createSession( + title = "오후 세션", + start = now.plusHours(1), + end = now.plusHours(3), + club = member.club, + ) + + every { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } returns member + every { attendanceRepository.findTodayByClubMemberId(member.id, any(), any()) } returns + listOf( + Attendance.create(pastSession, member), + Attendance.create(upcomingSession, member), + ) + + val result = queryService.findAttendance(member.club.id, member.user.id) + + result.title shouldBe "오후 세션" + } + + it("오늘 세션이 여러 개이고 모두 현재 시각 이전이면 마지막 세션을 반환한다") { + val member = ClubMemberTestFixture.createActiveMember() + val now = LocalDateTime.now() + val morningSession = + SessionTestFixture.createSession( + title = "오전 세션", + start = now.minusHours(5), + end = now.minusHours(3), + club = member.club, + ) + val afternoonSession = + SessionTestFixture.createSession( + title = "오후 세션", + start = now.minusHours(2), + end = now.minusHours(1), + club = member.club, + ) + + every { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } returns member + every { attendanceRepository.findTodayByClubMemberId(member.id, any(), any()) } returns + listOf( + Attendance.create(morningSession, member), + Attendance.create(afternoonSession, member), + ) + + val result = queryService.findAttendance(member.club.id, member.user.id) + + result.title shouldBe "오후 세션" + } + } + + describe("findAllDetailsByCurrentCardinal") { + it("현재 기수의 출석 상세 목록과 통계를 반환한다") { + val member = ClubMemberTestFixture.createActiveMember() + repeat(2) { member.attend() } + repeat(1) { member.absent() } + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = member.club, + cardinalNumber = 8, + ) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = member.club, + cardinal = 8, + title = "1주차", + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = member.club, + cardinal = 8, + title = "2주차", + ) + val attendances = listOf(Attendance.create(session1, member), Attendance.create(session2, member)) + + every { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } returns member + every { clubMemberCardinalPolicy.getCurrentCardinal(member) } returns cardinal + every { attendanceRepository.findAllByClubMemberIdAndCardinal(member.id, 8) } returns attendances + + val result = queryService.findAllDetailsByCurrentCardinal(member.club.id, member.user.id) + + result.attendanceCount shouldBe 2 + result.absenceCount shouldBe 1 + result.total shouldBe 3 + result.attendances shouldHaveSize 2 + result.attendances.map { it.title } shouldBe listOf("1주차", "2주차") + } + } + + describe("findAllAttendanceBySession") { + it("관리자는 세션별 출석 목록을 조회할 수 있다") { + val admin = ClubMemberTestFixture.createAdminMember() + val member = ClubMemberTestFixture.createActiveMember(club = admin.club) + val session = SessionTestFixture.createSession(id = 10L, club = admin.club, title = "세션") + val attendance = Attendance.create(session, member).also { it.attend() } + + every { clubPermissionPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { sessionReader.getById(session.id) } returns session + every { attendanceRepository.findAllBySessionAndClubMemberMemberStatus(session, any()) } returns + listOf(attendance) + + val result = queryService.findAllAttendanceBySession(admin.club.id, admin.user.id, session.id) + + result shouldHaveSize 1 + result.first().name shouldBe member.user.name + result.first().status shouldBe AttendanceStatus.ATTEND + } + + it("다른 동아리 세션이면 예외를 던진다") { + val admin = ClubMemberTestFixture.createAdminMember() + val otherSession = SessionTestFixture.createSession(id = 10L) + + every { clubPermissionPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { sessionReader.getById(otherSession.id) } returns otherSession + + shouldThrow { + queryService.findAllAttendanceBySession(admin.club.id, admin.user.id, otherSession.id) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt new file mode 100644 index 00000000..0d42cdbc --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt @@ -0,0 +1,95 @@ +package com.weeth.domain.attendance.domain.entity + +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.session.fixture.SessionTestFixture.createOneDaySession +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import java.time.LocalDate + +class AttendanceTest : + DescribeSpec({ + + val session = createOneDaySession(LocalDate.now(), 1, 1234, "테스트") + + describe("attend") { + it("상태를 ATTEND로 변경한다") { + val user = createActiveUser("테스트유저") + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) + + attendance.attend() + + attendance.status shouldBe AttendanceStatus.ATTEND + } + } + + describe("close") { + it("상태를 ABSENT로 변경한다") { + val user = createActiveUser("테스트유저") + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) + + attendance.close() + + attendance.status shouldBe AttendanceStatus.ABSENT + } + } + + describe("isPending") { + it("상태가 PENDING이면 true를 반환한다") { + val user = createActiveUser("테스트유저") + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) + + attendance.isPending() shouldBe true + } + + it("상태가 PENDING이 아니면 false를 반환한다") { + val user = createActiveUser("테스트유저") + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) + attendance.attend() + + attendance.isPending() shouldBe false + } + } + + describe("isWrong") { + it("코드가 일치하지 않으면 true를 반환한다") { + val user = createActiveUser("테스트유저") + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) + + attendance.isWrong(9999) shouldBe true + } + + it("코드가 일치하면 false를 반환한다") { + val user = createActiveUser("테스트유저") + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) + + attendance.isWrong(1234) shouldBe false + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt index f63aaff8..e243859d 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt @@ -2,11 +2,15 @@ package com.weeth.domain.attendance.domain.repository import com.weeth.config.TestContainersConfig import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.schedule.domain.entity.Meeting -import com.weeth.domain.schedule.domain.entity.enums.MeetingStatus -import com.weeth.domain.schedule.domain.repository.MeetingRepository +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.repository.SessionRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Status import com.weeth.domain.user.domain.repository.UserRepository import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldBeEmpty @@ -22,60 +26,91 @@ import java.time.LocalDateTime @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) class AttendanceRepositoryTest( private val attendanceRepository: AttendanceRepository, - private val meetingRepository: MeetingRepository, + private val sessionRepository: SessionRepository, private val userRepository: UserRepository, + private val clubRepository: ClubRepository, + private val clubMemberRepository: ClubMemberRepository, ) : DescribeSpec({ - lateinit var meeting: Meeting + lateinit var session: Session lateinit var activeUser1: User lateinit var activeUser2: User + lateinit var activeMember1: ClubMember + lateinit var activeMember2: ClubMember beforeEach { - meeting = - Meeting - .builder() - .title("1차 정기모임") - .start(LocalDateTime.now().minusHours(1)) - .end(LocalDateTime.now().plusHours(1)) - .code(1234) - .cardinal(1) - .meetingStatus(MeetingStatus.OPEN) - .build() - meetingRepository.save(meeting) + val club = clubRepository.save(ClubTestFixture.createClub()) + + session = + Session( + club = club, + title = "1차 정기모임", + start = LocalDateTime.now().minusHours(1), + end = LocalDateTime.now().plusHours(1), + code = 1234, + cardinal = 1, + status = SessionStatus.OPEN, + ) + sessionRepository.save(session) activeUser1 = - User - .builder() - .name("이지훈") - .status(Status.ACTIVE) - .build() + User.create( + name = "이지훈", + email = "lee.jihoon@test.com", + studentId = "", + tel = "", + department = "", + ) activeUser2 = - User - .builder() - .name("이강혁") - .status(Status.ACTIVE) - .build() + User.create( + name = "이강혁", + email = "lee.ganghyuk@test.com", + studentId = "", + tel = "", + department = "", + ) userRepository.saveAll(listOf(activeUser1, activeUser2)) activeUser1.accept() activeUser2.accept() userRepository.saveAll(listOf(activeUser1, activeUser2)) - attendanceRepository.save(Attendance(meeting, activeUser1)) - attendanceRepository.save(Attendance(meeting, activeUser2)) + activeMember1 = + clubMemberRepository.save( + ClubMember( + club = club, + user = activeUser1, + memberStatus = MemberStatus.ACTIVE, + ), + ) + activeMember2 = + clubMemberRepository.save( + ClubMember( + club = club, + user = activeUser2, + memberStatus = MemberStatus.ACTIVE, + ), + ) + + attendanceRepository.save(Attendance.create(session, activeMember1)) + attendanceRepository.save(Attendance.create(session, activeMember2)) } - describe("findAllByMeetingAndUserStatus") { - it("특정 정기모임 + 사용자 상태로 출석 목록 조회") { - val attendances = attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE) + describe("findAllBySessionAndClubMemberMemberStatus") { + it("특정 세션 + 멤버 상태로 출석 목록 조회") { + val attendances = + attendanceRepository.findAllBySessionAndClubMemberMemberStatus( + session, + MemberStatus.ACTIVE, + ) attendances shouldHaveSize 2 - attendances.map { it.user.name } shouldContainExactlyInAnyOrder listOf("이지훈", "이강혁") + attendances.map { it.clubMember.user.name } shouldContainExactlyInAnyOrder listOf("이지훈", "이강혁") } } - describe("deleteAllByMeeting") { - it("특정 정기모임의 모든 출석 레코드 삭제") { - attendanceRepository.deleteAllByMeeting(meeting) + describe("deleteAllBySession") { + it("특정 세션의 모든 출석 레코드 삭제") { + attendanceRepository.deleteAllBySession(session) attendanceRepository.findAll().shouldBeEmpty() } diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.kt deleted file mode 100644 index 5d8db16a..00000000 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.weeth.domain.attendance.domain.service - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser -import com.weeth.domain.schedule.fixture.ScheduleTestFixture.createMeeting -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify - -class AttendanceSaveServiceTest : - DescribeSpec({ - - val attendanceRepository = mockk() - val attendanceSaveService = AttendanceSaveService(attendanceRepository) - - describe("init") { - it("각 정기모임에 대한 Attendance 저장 후 user.add 호출") { - val user = mockk(relaxUnitFun = true) - val meetingFirst = createMeeting() - val meetingSecond = createMeeting() - - every { attendanceRepository.save(any()) } answers { firstArg() } - - attendanceSaveService.init(user, listOf(meetingFirst, meetingSecond)) - - verify(exactly = 2) { attendanceRepository.save(any()) } - verify(exactly = 2) { user.add(any()) } - } - } - - describe("saveAll") { - it("사용자 수만큼 Attendance 생성 후 saveAll 호출") { - val meeting = createMeeting() - val userFirst = createActiveUser("이지훈") - val userSecond = createActiveUser("이강혁") - - val listSlot = slot>() - every { attendanceRepository.saveAll(capture(listSlot)) } answers { firstArg() } - - attendanceSaveService.saveAll(listOf(userFirst, userSecond), meeting) - - val savedAttendances = listSlot.captured - savedAttendances shouldHaveSize 2 - savedAttendances.forEach { it.meeting shouldBe meeting } - savedAttendances.map { it.user } shouldBe listOf(userFirst, userSecond) - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateServiceTest.kt deleted file mode 100644 index 13ac8305..00000000 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateServiceTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.weeth.domain.attendance.domain.service - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.entity.enums.Status -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance -import com.weeth.domain.schedule.fixture.ScheduleTestFixture.createMeeting -import io.kotest.core.spec.style.DescribeSpec -import io.mockk.every -import io.mockk.spyk -import io.mockk.verify - -class AttendanceUpdateServiceTest : - DescribeSpec({ - - val attendanceUpdateService = AttendanceUpdateService() - - describe("attend") { - it("attendance.attend() + user.attend()을 호출한다") { - val meeting = createMeeting() - val userSpy = spyk(createActiveUser("이지훈")) - every { userSpy.attend() } returns Unit - - val attendanceSpy = spyk(createAttendance(meeting, userSpy)) - - attendanceUpdateService.attend(attendanceSpy) - - verify { attendanceSpy.attend() } - verify { userSpy.attend() } - } - } - - describe("close") { - it("pending만 close() + user.absent()을 호출한다") { - val meeting = createMeeting() - - val pendingUserSpy = spyk(createActiveUser("pending-user")) - val nonPendingUserSpy = spyk(createActiveUser("non-pending-user")) - every { pendingUserSpy.absent() } returns Unit - every { nonPendingUserSpy.absent() } returns Unit - - val pendingAttendanceSpy = spyk(createAttendance(meeting, pendingUserSpy)) - val nonPendingAttendanceSpy = spyk(createAttendance(meeting, nonPendingUserSpy)) - every { pendingAttendanceSpy.isPending } returns true - every { nonPendingAttendanceSpy.isPending } returns false - - attendanceUpdateService.close(listOf(pendingAttendanceSpy, nonPendingAttendanceSpy)) - - verify { pendingAttendanceSpy.close() } - verify { pendingUserSpy.absent() } - - verify(exactly = 0) { nonPendingAttendanceSpy.close() } - verify(exactly = 0) { nonPendingUserSpy.absent() } - } - } - - describe("updateUserAttendanceByStatus") { - it("ATTEND면 user.removeAttend(), 그 외에는 user.removeAbsent()") { - val meeting = createMeeting() - - val attendUserSpy = spyk(createActiveUser("attend-user")) - val absentUserSpy = spyk(createActiveUser("absent-user")) - every { attendUserSpy.removeAttend() } returns Unit - every { absentUserSpy.removeAbsent() } returns Unit - - val attendAttendanceSpy = spyk(createAttendance(meeting, attendUserSpy)) - val absentAttendanceSpy = spyk(createAttendance(meeting, absentUserSpy)) - every { attendAttendanceSpy.status } returns Status.ATTEND - every { absentAttendanceSpy.status } returns Status.ABSENT - every { attendAttendanceSpy.user } returns attendUserSpy - every { absentAttendanceSpy.user } returns absentUserSpy - - attendanceUpdateService.updateUserAttendanceByStatus( - listOf(attendAttendanceSpy, absentAttendanceSpy), - ) - - verify { attendUserSpy.removeAttend() } - verify { absentUserSpy.removeAbsent() } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt index 864321cf..0c46d535 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt @@ -1,93 +1,29 @@ package com.weeth.domain.attendance.fixture import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.schedule.domain.entity.Meeting +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Department -import com.weeth.domain.user.domain.entity.enums.Position -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status import org.springframework.test.util.ReflectionTestUtils -import java.time.LocalDate -import java.time.LocalDateTime +import java.util.UUID object AttendanceTestFixture { fun createActiveUser(name: String): User = User - .builder() - .name(name) - .status(Status.ACTIVE) - .build() + .create( + name = name, + email = "attendance-${UUID.randomUUID()}@test.com", + studentId = "", + tel = "", + department = "", + ).also { it.accept() } - fun createAdminUser(name: String): User = - User - .builder() - .name(name) - .status(Status.ACTIVE) - .role(Role.ADMIN) - .build() - - fun createActiveUserWithAttendances( - name: String, - meetings: List, - ): User { - val user = createActiveUser(name) - initAttendancesField(user) - meetings.forEach { meeting -> - val attendance = createAttendance(meeting, user) - user.add(attendance) - } - return user - } - - fun createAdminUserWithAttendances( - name: String, - meetings: List, - ): User { - val user = createAdminUser(name) - initAttendancesField(user) - meetings.forEach { meeting -> - val attendance = createAttendance(meeting, user) - user.add(attendance) - } - return user - } + fun createAdminUser(name: String): User = createActiveUser(name) fun createAttendance( - meeting: Meeting, - user: User, - ): Attendance = Attendance(meeting, user) - - fun createOneDayMeeting( - date: LocalDate, - cardinal: Int, - code: Int, - title: String, - ): Meeting = - Meeting - .builder() - .title(title) - .location("Test Location") - .start(date.atTime(10, 0)) - .end(date.atTime(12, 0)) - .code(code) - .cardinal(cardinal) - .build() - - fun createInProgressMeeting( - cardinal: Int, - code: Int, - title: String, - ): Meeting = - Meeting - .builder() - .title(title) - .location("Test Location") - .start(LocalDateTime.now().minusMinutes(5)) - .end(LocalDateTime.now().plusMinutes(5)) - .code(code) - .cardinal(cardinal) - .build() + session: Session, + clubMember: ClubMember, + ): Attendance = Attendance.create(session, clubMember) fun setAttendanceId( attendance: Attendance, @@ -96,41 +32,12 @@ object AttendanceTestFixture { ReflectionTestUtils.setField(attendance, "id", id) } - fun setUserAttendanceStats( - user: User, - attendanceCount: Int, - absenceCount: Int, - ) { - ReflectionTestUtils.setField(user, "attendanceCount", attendanceCount) - ReflectionTestUtils.setField(user, "absenceCount", absenceCount) - } - fun enrichUserProfile( user: User, - position: Position, - department: Department, + department: String, studentId: String, ) { - ReflectionTestUtils.setField(user, "position", position) ReflectionTestUtils.setField(user, "department", department) ReflectionTestUtils.setField(user, "studentId", studentId) } - - fun enrichUserProfile( - user: User, - position: Position, - departmentKoreanValue: String, - studentId: String, - ) { - ReflectionTestUtils.setField(user, "position", position) - val department = Department.to(departmentKoreanValue) - ReflectionTestUtils.setField(user, "department", department) - ReflectionTestUtils.setField(user, "studentId", studentId) - } - - private fun initAttendancesField(user: User) { - if (user.attendances == null) { - ReflectionTestUtils.setField(user, "attendances", mutableListOf()) - } - } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListenerTest.kt b/src/test/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListenerTest.kt new file mode 100644 index 00000000..ab11cff7 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListenerTest.kt @@ -0,0 +1,70 @@ +package com.weeth.domain.attendance.infrastructure + +import com.weeth.domain.attendance.application.event.AttendanceSseEvent +import com.weeth.domain.attendance.domain.port.SseBroadcastPort +import com.weeth.domain.session.domain.repository.SessionReader +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.data.redis.connection.Message + +class QrExpiredEventListenerTest : + DescribeSpec({ + val sessionReader = mockk() + val sseBroadcastPort = mockk(relaxed = true) + val listener = QrExpiredEventListener(sessionReader, sseBroadcastPort) + + beforeTest { clearMocks(sessionReader, sseBroadcastPort) } + + fun message(key: String): Message = mockk { every { body } returns key.toByteArray() } + + describe("onMessage") { + context("qr:{sessionId} 키가 만료된 경우") { + it("해당 클럽에 qr-close를 broadcast한다") { + every { sessionReader.findClubIdById(42L) } returns 7L + + listener.onMessage(message("qr:42"), null) + + verify { sseBroadcastPort.broadcast(7L, AttendanceSseEvent.QR_CLOSE, null) } + } + } + + context("qr: 접두사가 아닌 키가 만료된 경우") { + it("broadcast하지 않는다") { + listener.onMessage(message("other:42"), null) + + verify(exactly = 0) { sseBroadcastPort.broadcast(any(), any(), any()) } + } + } + + context("sessionId가 숫자가 아닌 경우") { + it("broadcast하지 않는다") { + listener.onMessage(message("qr:invalid"), null) + + verify(exactly = 0) { sseBroadcastPort.broadcast(any(), any(), any()) } + } + } + + context("세션이 존재하지 않는 경우") { + it("broadcast하지 않는다") { + every { sessionReader.findClubIdById(99L) } returns null + + listener.onMessage(message("qr:99"), null) + + verify(exactly = 0) { sseBroadcastPort.broadcast(any(), any(), any()) } + } + } + + context("broadcast 중 예외가 발생하는 경우") { + it("예외가 전파되지 않는다") { + every { sessionReader.findClubIdById(42L) } returns 7L + every { sseBroadcastPort.broadcast(any(), any(), any()) } throws RuntimeException("network error") + + shouldNotThrow { listener.onMessage(message("qr:42"), null) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStoreTest.kt b/src/test/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStoreTest.kt new file mode 100644 index 00000000..1f4c906f --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStoreTest.kt @@ -0,0 +1,157 @@ +package com.weeth.domain.attendance.infrastructure + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import io.mockk.verify +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class SseEmitterStoreTest : + StringSpec({ + val clubId = 1L + val userId = 100L + + "emitter를 replace하면 getAllByClub에서 조회된다" { + val store = SseEmitterStore() + val emitter = mockk(relaxed = true) + + store.replace(clubId, userId, emitter) + + store.getAllByClub(clubId) shouldHaveSize 1 + } + + "같은 userId로 replace하면 기존 emitter가 complete된다" { + val store = SseEmitterStore() + val oldEmitter = mockk(relaxed = true) + val newEmitter = mockk(relaxed = true) + + store.replace(clubId, userId, oldEmitter) + store.replace(clubId, userId, newEmitter) + + verify(exactly = 1) { oldEmitter.complete() } + store.getAllByClub(clubId) shouldHaveSize 1 + store.getAllByClub(clubId).first().second shouldBe newEmitter + } + + "emitter를 제거하면 조회되지 않는다" { + val store = SseEmitterStore() + val emitter = mockk(relaxed = true) + store.replace(clubId, userId, emitter) + + store.remove(clubId, userId, emitter) + + store.getAllByClub(clubId).shouldBeEmpty() + } + + "마지막 emitter 제거 시 내부 map 엔트리가 정리된다" { + val store = SseEmitterStore() + val emitter = mockk(relaxed = true) + store.replace(clubId, userId, emitter) + + store.remove(clubId, userId, emitter) + + store.getAllByClub(clubId).shouldBeEmpty() + } + + "재연결 시 old emitter의 cleanup이 new emitter를 제거하지 않는다" { + val store = SseEmitterStore() + val oldEmitter = mockk(relaxed = true) + val newEmitter = mockk(relaxed = true) + + store.replace(clubId, userId, oldEmitter) + store.replace(clubId, userId, newEmitter) + store.remove(clubId, userId, oldEmitter) // old emitter의 onCompletion 시뮬레이션 + + store.getAllByClub(clubId) shouldHaveSize 1 + store.getAllByClub(clubId).first().second shouldBe newEmitter + } + + "getAllByClub은 userId와 emitter 쌍을 반환한다" { + val store = SseEmitterStore() + val emitter = mockk(relaxed = true) + store.replace(clubId, userId, emitter) + + val result = store.getAllByClub(clubId) + + result.first().first shouldBe userId + result.first().second shouldBe emitter + } + + "존재하지 않는 clubId로 조회하면 빈 리스트를 반환한다" { + val store = SseEmitterStore() + + store.getAllByClub(999L).shouldBeEmpty() + } + + "동시에 여러 스레드에서 replace를 호출해도 유저별 emitter가 유실되지 않는다" { + val store = SseEmitterStore() + val threadCount = 100 + val latch = CountDownLatch(threadCount) + val executor = Executors.newFixedThreadPool(threadCount) + + repeat(threadCount) { i -> + executor.submit { + try { + store.replace(clubId, userId + i, mockk(relaxed = true)) + } finally { + latch.countDown() + } + } + } + + try { + latch.await(10, TimeUnit.SECONDS) shouldBe true + } finally { + executor.shutdown() + executor.awaitTermination(5, TimeUnit.SECONDS) + } + + store.getAllByClub(clubId) shouldHaveSize threadCount + } + + "동시에 replace와 remove를 호출해도 store 상태가 일관성을 유지한다" { + val store = SseEmitterStore() + val threadCount = 50 + val executor = Executors.newFixedThreadPool(threadCount * 2) + + val userIds = List(threadCount) { userId + it } + val initialEmitters = List(threadCount) { mockk(relaxed = true) } + userIds.forEachIndexed { i, uid -> store.replace(clubId, uid, initialEmitters[i]) } + + val newEmitters = List(threadCount) { mockk(relaxed = true) } + val latch = CountDownLatch(threadCount * 2) + + repeat(threadCount) { i -> + executor.submit { + try { + store.replace(clubId, userIds[i], newEmitters[i]) + } finally { + latch.countDown() + } + } + executor.submit { + try { + store.remove(clubId, userIds[i], initialEmitters[i]) + } finally { + latch.countDown() + } + } + } + + try { + latch.await(10, TimeUnit.SECONDS) shouldBe true + } finally { + executor.shutdown() + executor.awaitTermination(5, TimeUnit.SECONDS) + } + + // replace 후 remove가 실행됐거나, remove 후 replace가 실행된 상태 — 어느 쪽이든 초기 emitter는 없어야 함 + val inStore = store.getAllByClub(clubId).map { it.second }.toSet() + initialEmitters.none { it in inStore } shouldBe true + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index f8cbe2d4..e2869af4 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -1,38 +1,111 @@ package com.weeth.domain.board.application.mapper +import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.user.application.dto.response.UserInfo import com.weeth.domain.user.domain.entity.User -import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe -import org.mapstruct.factory.Mappers +import io.mockk.every +import io.mockk.mockk +import java.time.LocalDateTime +import kotlin.collections.emptyList class PostMapperTest : - StringSpec({ - - val mapper = Mappers.getMapper(PostMapper::class.java) - - "Post를 PostDTO.SaveResponse로 변환" { - val testUser = - User - .builder() - .id(1L) - .name("테스트유저") - .email("test@weeth.com") - .build() - - val testPost = - Post - .builder() - .id(1L) - .title("테스트 게시글") - .user(testUser) - .content("테스트 내용입니다.") - .build() - - val response = mapper.toSaveResponse(testPost) - - response.shouldNotBeNull() - response.id() shouldBe testPost.id + DescribeSpec({ + val fileAccessUrlPort = mockk() + val mapper = PostMapper(fileAccessUrlPort) + val now = LocalDateTime.now() + val user = mockk() + val board = mockk() + val post = mockk() + val authorMember = mockk() + + every { user.id } returns 1L + every { user.name } returns "테스터" + + every { board.id } returns 10L + every { board.name } returns "일반 게시판" + every { board.canWriteBy(any()) } returns true + every { board.isCommentEnabled } returns true + + every { authorMember.memberRole } returns MemberRole.USER + every { authorMember.profileImageStorageKey } returns null + every { authorMember.user } returns user + + every { post.id } returns 1L + every { post.title } returns "제목" + every { post.content } returns "내용" + every { post.clubMember } returns authorMember + every { post.board } returns board + every { post.commentCount } returns 2 + every { post.likeCount } returns 0 + every { post.createdAt } returns now.minusHours(1) + every { post.modifiedAt } returns now + + describe("toListResponse") { + it("24시간 이내 생성된 게시글은 isNew=true") { + val response = + mapper.toListResponse( + post, + files = emptyList(), + now = now, + isLiked = false, + memberRole = MemberRole.USER, + ) + + response.id shouldBe 1L + response.fileUrls shouldBe emptyList() + response.isNew shouldBe true + } + } + + describe("toDetailResponse") { + it("댓글/파일 목록을 포함해 상세 응답으로 변환한다") { + val comments = + listOf( + CommentResponse( + id = 10L, + author = UserInfo(id = 2L, name = "댓글작성자", profileImageUrl = null, role = MemberRole.USER), + content = "댓글", + time = LocalDateTime.now(), + fileUrls = emptyList(), + children = emptyList(), + ), + ) + val files = + listOf( + FileResponse( + fileId = 5L, + fileName = "a.png", + fileUrl = "https://cdn/a.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", + fileSize = 100, + contentType = "image/png", + status = FileStatus.UPLOADED, + ), + ) + + val response = + mapper.toDetailResponse( + post, + comments, + files, + isLiked = false, + now = now, + memberRole = MemberRole.USER, + ) + + response.id shouldBe 1L + response.commentCount shouldBe 2 + response.comments.size shouldBe 1 + response.fileUrls.size shouldBe 1 + } } }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt deleted file mode 100644 index ad55bd62..00000000 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt +++ /dev/null @@ -1,245 +0,0 @@ -package com.weeth.domain.board.application.usecase - -import com.weeth.domain.board.application.dto.NoticeDTO -import com.weeth.domain.board.application.mapper.NoticeMapper -import com.weeth.domain.board.domain.entity.Notice -import com.weeth.domain.board.domain.service.NoticeDeleteService -import com.weeth.domain.board.domain.service.NoticeFindService -import com.weeth.domain.board.domain.service.NoticeSaveService -import com.weeth.domain.board.domain.service.NoticeUpdateService -import com.weeth.domain.comment.application.mapper.CommentMapper -import com.weeth.domain.user.domain.service.UserGetService -import com.weeth.domain.board.fixture.NoticeTestFixture -import com.weeth.domain.file.application.dto.request.FileSaveRequest -import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.service.FileDeleteService -import com.weeth.domain.file.domain.service.FileGetService -import com.weeth.domain.file.domain.service.FileSaveService -import com.weeth.domain.file.fixture.FileTestFixture -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Department -import com.weeth.domain.user.domain.entity.enums.Position -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.SliceImpl -import org.springframework.data.domain.Sort -import org.springframework.test.util.ReflectionTestUtils - -class NoticeUsecaseImplTest : - DescribeSpec({ - - val noticeSaveService = mockk(relaxUnitFun = true) - val noticeFindService = mockk() - val noticeUpdateService = mockk(relaxUnitFun = true) - val noticeDeleteService = mockk(relaxUnitFun = true) - val userGetService = mockk() - val fileSaveService = mockk(relaxUnitFun = true) - val fileGetService = mockk() - val fileDeleteService = mockk(relaxUnitFun = true) - val noticeMapper = mockk() - val commentMapper = mockk() - val fileMapper = mockk() - - val noticeUsecase = - NoticeUsecaseImpl( - noticeSaveService, - noticeFindService, - noticeUpdateService, - noticeDeleteService, - userGetService, - fileSaveService, - fileGetService, - fileDeleteService, - noticeMapper, - commentMapper, - fileMapper, - ) - - describe("findNotices") { - it("공지사항이 최신순으로 정렬된다") { - val user = - User - .builder() - .email("abc@test.com") - .name("홍길동") - .position(Position.BE) - .department(Department.SW) - .role(Role.USER) - .build() - - val notices = - (0 until 5).map { i -> - NoticeTestFixture.createNotice(title = "공지$i", user = user).also { - ReflectionTestUtils.setField(it, "id", (i + 1).toLong()) - } - } - - val pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "id")) - val slice = SliceImpl(listOf(notices[4], notices[3], notices[2]), pageable, true) - - every { noticeFindService.findRecentNotices(any()) } returns slice - every { fileGetService.findAllByNotice(any()) } returns listOf() - every { noticeMapper.toAll(any(), any()) } answers { - val notice = firstArg() - NoticeDTO.ResponseAll( - notice.id, - notice.user?.name ?: "", - notice.user?.position ?: Position.BE, - notice.user?.role ?: Role.USER, - notice.title, - notice.content, - notice.createdAt, - notice.commentCount, - false, - ) - } - - val noticeResponses = noticeUsecase.findNotices(0, 3) - - noticeResponses.shouldNotBeNull() - noticeResponses.content shouldHaveSize 3 - noticeResponses.content.map { it.title() } shouldContainExactly - listOf(notices[4].title, notices[3].title, notices[2].title) - noticeResponses.hasNext().shouldBeTrue() - - verify(exactly = 1) { noticeFindService.findRecentNotices(pageable) } - } - } - - describe("searchNotice") { - it("공지사항 검색시 결과와 파일 존재여부가 정상적으로 반환") { - val user = - User - .builder() - .email("abc@test.com") - .name("홍길동") - .position(Position.BE) - .department(Department.SW) - .role(Role.USER) - .build() - - val notices = mutableListOf() - for (i in 0 until 3) { - val notice = NoticeTestFixture.createNotice(title = "공지$i", user = user) - ReflectionTestUtils.setField(notice, "id", (i + 1).toLong()) - notices.add(notice) - } - for (i in 3 until 6) { - val notice = NoticeTestFixture.createNotice(title = "검색$i", user = user) - ReflectionTestUtils.setField(notice, "id", (i + 1).toLong()) - notices.add(notice) - } - - val pageable = PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "id")) - val slice = SliceImpl(listOf(notices[5], notices[4], notices[3]), pageable, false) - - every { noticeFindService.search(any(), any()) } returns slice - every { fileGetService.findAllByNotice(any()) } answers { - val noticeId = firstArg() - if (noticeId % 2 == 0L) { - listOf( - File - .builder() - .notice(notices[(noticeId - 1).toInt()]) - .build(), - ) - } else { - listOf() - } - } - every { noticeMapper.toAll(any(), any()) } answers { - val notice = firstArg() - val fileExists = secondArg() - NoticeDTO.ResponseAll( - notice.id, - notice.user?.name ?: "", - notice.user?.position ?: Position.BE, - notice.user?.role ?: Role.USER, - notice.title, - notice.content, - notice.createdAt, - notice.commentCount, - fileExists, - ) - } - - val noticeResponses = noticeUsecase.searchNotice("검색", 0, 5) - - noticeResponses.shouldNotBeNull() - noticeResponses.content shouldHaveSize 3 - noticeResponses.content.map { it.title() } shouldContainExactly - listOf(notices[5].title, notices[4].title, notices[3].title) - noticeResponses.hasNext().shouldBeFalse() - - noticeResponses.content[0].hasFile().shouldBeTrue() - noticeResponses.content[1].hasFile().shouldBeFalse() - - verify(exactly = 1) { noticeFindService.search("검색", pageable) } - } - } - - describe("update") { - it("공지사항 수정 시 기존 파일 삭제 후 새 파일로 업데이트된다") { - val noticeId = 1L - val userId = 1L - - val user = UserTestFixture.createActiveUser1(userId) - val notice = NoticeTestFixture.createNotice(id = noticeId, title = "기존 제목", user = user) - - val oldFile = FileTestFixture.createFile(1L, "old.pdf", "https://example.com/old.pdf", notice) - val oldFiles = listOf(oldFile) - - val dto = - NoticeDTO.Update( - "수정된 제목", - "수정된 내용", - listOf(FileSaveRequest("new.pdf", "https://example.com/new.pdf")), - ) - - val newFile = FileTestFixture.createFile(2L, "new.pdf", "https://example.com/new.pdf", notice) - val newFiles = listOf(newFile) - - val expectedResponse = NoticeDTO.SaveResponse(noticeId) - - every { noticeFindService.find(noticeId) } returns notice - every { fileGetService.findAllByNotice(noticeId) } returns oldFiles - every { fileMapper.toFileList(dto.files(), notice) } returns newFiles - every { noticeMapper.toSaveResponse(notice) } returns expectedResponse - - val response = noticeUsecase.update(noticeId, dto, userId) - - response shouldBe expectedResponse - - verify { noticeFindService.find(noticeId) } - verify { fileGetService.findAllByNotice(noticeId) } - verify { fileDeleteService.delete(oldFiles) } - verify { fileMapper.toFileList(dto.files(), notice) } - verify { fileSaveService.save(newFiles) } - verify { noticeUpdateService.update(notice, dto) } - } - - it("공지사항 엔티티 update() 호출 시 제목과 내용이 변경된다") { - val userId = 1L - val user = UserTestFixture.createActiveUser1(userId) - val notice = NoticeTestFixture.createNotice(id = 1L, title = "기존 제목", user = user) - val dto = NoticeDTO.Update("수정된 제목", "수정된 내용", listOf()) - - notice.update(dto) - - notice.title shouldBe dto.title() - notice.content shouldBe dto.content() - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt deleted file mode 100644 index 200629e4..00000000 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt +++ /dev/null @@ -1,282 +0,0 @@ -package com.weeth.domain.board.application.usecase - -import com.weeth.domain.board.application.dto.PartPostDTO -import com.weeth.domain.board.application.dto.PostDTO -import com.weeth.domain.board.application.exception.CategoryAccessDeniedException -import com.weeth.domain.board.application.mapper.PostMapper -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.domain.entity.enums.Part -import com.weeth.domain.board.domain.service.PostDeleteService -import com.weeth.domain.board.domain.service.PostFindService -import com.weeth.domain.board.domain.service.PostSaveService -import com.weeth.domain.board.domain.service.PostUpdateService -import com.weeth.domain.board.fixture.PostTestFixture -import com.weeth.domain.comment.application.mapper.CommentMapper -import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.service.FileDeleteService -import com.weeth.domain.file.domain.service.FileGetService -import com.weeth.domain.file.domain.service.FileSaveService -import com.weeth.domain.file.fixture.FileTestFixture -import com.weeth.domain.user.domain.service.CardinalGetService -import com.weeth.domain.user.domain.service.UserCardinalGetService -import com.weeth.domain.user.domain.service.UserGetService -import com.weeth.domain.user.fixture.CardinalTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.assertions.throwables.shouldNotThrowAny -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Pageable -import org.springframework.data.domain.SliceImpl -import org.springframework.data.domain.Sort - -class PostUseCaseImplTest : - DescribeSpec({ - - val postSaveService = mockk() - val postFindService = mockk() - val postUpdateService = mockk() - val postDeleteService = mockk() - val userGetService = mockk() - val userCardinalGetService = mockk() - val cardinalGetService = mockk() - val fileSaveService = mockk(relaxUnitFun = true) - val fileGetService = mockk() - val fileDeleteService = mockk() - val mapper = mockk() - val fileMapper = mockk() - val commentMapper = mockk() - - val postUseCase = - PostUseCaseImpl( - postSaveService, - postFindService, - postUpdateService, - postDeleteService, - userGetService, - userCardinalGetService, - cardinalGetService, - fileSaveService, - fileGetService, - fileDeleteService, - mapper, - fileMapper, - commentMapper, - ) - - describe("saveEducation") { - it("교육 게시글 저장 성공") { - val userId = 1L - val postId = 1L - - val request = PostDTO.SaveEducation("제목1", "내용", listOf(Part.BE), 1, listOf()) - val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(postId, "제목1", Category.Education) - - every { userGetService.find(userId) } returns user - every { mapper.fromEducationDto(request, user) } returns post - every { postSaveService.save(post) } returns post - every { fileMapper.toFileList(request.files(), post) } returns listOf() - every { mapper.toSaveResponse(post) } returns PostDTO.SaveResponse(postId) - - val response = postUseCase.saveEducation(request, userId) - - response.id() shouldBe postId - verify { userGetService.find(userId) } - verify { postSaveService.save(post) } - verify { mapper.toSaveResponse(post) } - } - } - - describe("save") { - context("관리자 권한이 없는 사용자가 교육 게시글 생성 시") { - it("예외를 던진다") { - val userId = 1L - val request = PostDTO.Save("제목", "내용", Category.Education, null, 1, Part.BE, 1, listOf()) - val user = UserTestFixture.createActiveUser1(1L) - - every { userGetService.find(userId) } returns user - - shouldThrow { - postUseCase.save(request, userId) - } - } - } - } - - describe("findPartPosts") { - it("특정 파트와 주차 조건으로 게시글 목록 조회 성공") { - val dto = PartPostDTO(Part.BE, Category.Education, 1, 2, "스터디1") - val pageNumber = 0 - val pageSize = 5 - val user = UserTestFixture.createActiveUser1() - - val post2 = - PostTestFixture.createEducationPost( - 2L, - user, - "게시글2", - Category.Education, - listOf(Part.BE), - 1, - 2, - ) - val postSlice = SliceImpl(listOf(post2)) - val response2 = PostTestFixture.createResponseAll(post2) - - every { - postFindService.findByPartAndOptionalFilters( - dto.part(), - dto.category(), - dto.cardinalNumber(), - dto.studyName(), - dto.week(), - PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")), - ) - } returns postSlice - - every { mapper.toAll(post2, false) } returns response2 - every { fileGetService.findAllByPost(post2.id) } returns emptyList() - - val result = postUseCase.findPartPosts(dto, pageNumber, pageSize) - - result.shouldNotBeNull() - result.content shouldHaveSize 1 - result.content[0].title() shouldBe "게시글2" - result.content[0].hasFile().shouldBeFalse() - - verify { - postFindService.findByPartAndOptionalFilters( - dto.part(), - dto.category(), - dto.cardinalNumber(), - dto.studyName(), - dto.week(), - PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")), - ) - } - } - } - - describe("findEducationPosts") { - it("관리자 권한 사용자가 교육 게시글 목록 조회 시 성공적으로 반환한다") { - val userId = 1L - val part = Part.BE - val cardinalNumber = 1 - val pageNumber = 0 - val pageSize = 5 - - val adminUser = UserTestFixture.createAdmin(userId) - - val post1 = - PostTestFixture.createEducationPost( - 1L, - adminUser, - "교육글1", - Category.Education, - listOf(Part.BE), - 1, - 1, - ) - val post2 = - PostTestFixture.createEducationPost( - 2L, - adminUser, - "교육글2", - Category.Education, - listOf(Part.BE), - 1, - 2, - ) - val postSlice = SliceImpl(listOf(post1, post2)) - - val response1 = PostTestFixture.createResponseEducationAll(post1, false) - val response2 = PostTestFixture.createResponseEducationAll(post2, false) - - every { userGetService.find(userId) } returns adminUser - every { postFindService.findByCategory(part, Category.Education, cardinalNumber, pageNumber, pageSize) } returns postSlice - every { mapper.toEducationAll(post1, false) } returns response1 - every { mapper.toEducationAll(post2, false) } returns response2 - every { fileGetService.findAllByPost(post1.id) } returns emptyList() - every { fileGetService.findAllByPost(post2.id) } returns emptyList() - - val result = postUseCase.findEducationPosts(userId, part, cardinalNumber, pageNumber, pageSize) - - result.shouldNotBeNull() - result.content shouldHaveSize 2 - result.content.map { it.title() } shouldContainExactly listOf("교육글1", "교육글2") - - verify { postFindService.findByCategory(part, Category.Education, cardinalNumber, pageNumber, pageSize) } - verify { mapper.toEducationAll(post1, false) } - verify { mapper.toEducationAll(post2, false) } - } - - it("본인이 속하지 않은 교육 자료를 검색하면 빈 리스트를 반환한다") { - val userId = 1L - val part = Part.BE - val cardinalNumber = 3 - val pageNumber = 0 - val pageSize = 5 - - val user = UserTestFixture.createActiveUser1(userId) - val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 1, year = 2025, semester = 1) - - every { userGetService.find(userId) } returns user - every { cardinalGetService.findByUserSide(cardinalNumber) } returns cardinal - every { userCardinalGetService.notContains(user, cardinal) } returns true - - val result = postUseCase.findEducationPosts(userId, part, cardinalNumber, pageNumber, pageSize) - - result.shouldNotBeNull() - result.content.shouldBeEmpty() - result.hasNext().shouldBeFalse() - - verify { userGetService.find(userId) } - verify { cardinalGetService.findByUserSide(cardinalNumber) } - verify { userCardinalGetService.notContains(user, cardinal) } - verify(exactly = 0) { postFindService.findEducationByCardinal(any(), any(), any()) } - } - } - - describe("findStudyNames") { - it("스터디가 없을 시 예외가 발생하지 않는다") { - val part = Part.BE - val emptyNames = listOf() - val expectedResponse = PostDTO.ResponseStudyNames(emptyNames) - - every { postFindService.findByPart(part) } returns emptyNames - every { mapper.toStudyNames(emptyNames) } returns expectedResponse - - shouldNotThrowAny { - postUseCase.findStudyNames(part) - } - - verify { postFindService.findByPart(part) } - verify { mapper.toStudyNames(emptyNames) } - } - } - - describe("checkFileExistsByPost") { - it("파일이 존재하는 경우 true를 반환한다") { - val postId = 1L - val file = FileTestFixture.createFile(postId, "파일1", "url1") - - every { fileGetService.findAllByPost(postId) } returns listOf(file) - - val fileExists = postUseCase.checkFileExistsByPost(postId) - - fileExists.shouldBeTrue() - verify { fileGetService.findAllByPost(postId) } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt new file mode 100644 index 00000000..d1c9e260 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -0,0 +1,418 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.ReorderBoardsRequest +import com.weeth.domain.board.application.dto.request.UpdateBoardRequest +import com.weeth.domain.board.application.exception.BoardCreateLockTimeoutException +import com.weeth.domain.board.application.exception.BoardLimitExceededException +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.BoardNotInClubException +import com.weeth.domain.board.application.exception.DeletedBoardNotReorderableException +import com.weeth.domain.board.application.exception.DuplicateBoardIdException +import com.weeth.domain.board.application.exception.DuplicateBoardNameException +import com.weeth.domain.board.application.exception.FixedBoardNotDeletableException +import com.weeth.domain.board.application.exception.FixedBoardNotRenamableException +import com.weeth.domain.board.application.exception.FixedBoardNotReorderableException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.dao.PessimisticLockingFailureException + +class ManageBoardUseCaseTest : + DescribeSpec({ + val boardRepository = mockk() + val boardMapper = BoardMapper() + val clubReader = mockk() + val clubPermissionPolicy = mockk(relaxed = true) + val useCase = ManageBoardUseCase(boardRepository, boardMapper, clubReader, clubPermissionPolicy) + + val club = ClubTestFixture.createClub() + val clubId = club.id + val userId = 10L + + beforeTest { + clearMocks(boardRepository, clubReader, clubPermissionPolicy) + every { boardRepository.save(any()) } answers { firstArg() } + every { clubReader.getClubByIdForUpdate(clubId) } returns club + every { boardRepository.findMaxActiveDisplayOrderByClubId(clubId) } returns -1 + every { boardRepository.findMaxDisplayOrderByClubId(clubId) } returns -1 + every { boardRepository.existsByClubIdAndNameAndIsDeletedFalse(any(), any()) } returns false + every { boardRepository.existsByClubIdAndNameAndIsDeletedFalseAndIdNot(any(), any(), any()) } returns false + every { boardRepository.countByClubIdAndIsDeletedFalse(any()) } returns 0 + } + + describe("create") { + it("요청값으로 게시판과 설정을 생성한다") { + val request = + CreateBoardRequest( + name = "운영 게시판", + description = "운영진만 사용하는 게시판입니다.", + type = BoardType.GENERAL, + commentEnabled = false, + writePermission = MemberRole.ADMIN, + isPrivate = true, + ) + + val result = useCase.create(clubId, request, userId) + + result.name shouldBe "운영 게시판" + result.description shouldBe "운영진만 사용하는 게시판입니다." + result.type shouldBe BoardType.GENERAL + result.commentEnabled shouldBe false + result.writePermission shouldBe MemberRole.ADMIN + result.isPrivate shouldBe true + } + + it("기존 게시판이 없으면 displayOrder 0으로 생성한다") { + every { boardRepository.findMaxActiveDisplayOrderByClubId(clubId) } returns -1 + val request = + CreateBoardRequest( + name = "첫 게시판", + description = "첫 번째 게시판 설명", + type = BoardType.GENERAL, + commentEnabled = true, + writePermission = MemberRole.USER, + isPrivate = false, + ) + + val result = useCase.create(clubId, request, userId) + + result.displayOrder shouldBe 0 + } + + it("기존 게시판이 있으면 마지막 순서 다음으로 생성한다") { + every { boardRepository.findMaxActiveDisplayOrderByClubId(clubId) } returns 2 + val request = + CreateBoardRequest( + name = "새 게시판", + description = "새 게시판 설명", + type = BoardType.GENERAL, + commentEnabled = true, + writePermission = MemberRole.USER, + isPrivate = false, + ) + + val result = useCase.create(clubId, request, userId) + + result.displayOrder shouldBe 3 + } + + it("Club 락 획득 타임아웃 시 BoardCreateLockTimeoutException을 던진다") { + every { clubReader.getClubByIdForUpdate(clubId) } throws PessimisticLockingFailureException("") + val request = + CreateBoardRequest( + name = "새 게시판", + description = "새 게시판 설명", + type = BoardType.GENERAL, + commentEnabled = true, + writePermission = MemberRole.USER, + isPrivate = false, + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + + it("총 게시판 수가 4개 이상이면 예외를 던진다") { + every { boardRepository.countByClubIdAndIsDeletedFalse(clubId) } returns 4 + val request = + CreateBoardRequest( + name = "초과 게시판", + description = "초과 게시판 설명", + type = BoardType.GENERAL, + commentEnabled = true, + writePermission = MemberRole.USER, + isPrivate = false, + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + + it("같은 클럽에 동일한 이름의 게시판이 이미 있으면 예외를 던진다") { + every { boardRepository.existsByClubIdAndNameAndIsDeletedFalse(clubId, "중복 이름") } returns true + val request = + CreateBoardRequest( + name = "중복 이름", + description = "중복 이름 설명", + type = BoardType.GENERAL, + commentEnabled = true, + writePermission = MemberRole.USER, + isPrivate = false, + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + } + + describe("update") { + it("일부 필드만 전달되면 해당 필드만 갱신한다") { + val board = BoardTestFixture.create(club = club, name = "기존", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + + val result = + useCase.update( + clubId, + 1L, + UpdateBoardRequest(name = "변경", description = "변경된 설명", isPrivate = true), + userId, + ) + + result.name shouldBe "변경" + result.description shouldBe "변경된 설명" + result.commentEnabled shouldBe true + result.writePermission shouldBe MemberRole.USER + result.isPrivate shouldBe true + } + + it("아무 필드도 전달되지 않으면 기존 값이 그대로 유지된다") { + val board = BoardTestFixture.create(club = club, name = "기존", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + + val result = useCase.update(clubId, 1L, UpdateBoardRequest(), userId) + + result.name shouldBe "기존" + result.description shouldBe "일반 게시판 설명" + result.commentEnabled shouldBe true + result.writePermission shouldBe MemberRole.USER + result.isPrivate shouldBe false + } + + it("존재하지 않는 게시판이면 예외를 던진다") { + every { boardRepository.findByIdAndIsDeletedFalse(999L) } returns null + + shouldThrow { + useCase.update(clubId, 999L, UpdateBoardRequest(name = "변경"), userId) + } + } + + it("변경할 이름이 같은 클럽의 다른 게시판 이름과 중복되면 예외를 던진다") { + val board = BoardTestFixture.create(id = 1L, club = club, name = "기존", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + every { boardRepository.existsByClubIdAndNameAndIsDeletedFalseAndIdNot(clubId, "중복 이름", 1L) } returns + true + + shouldThrow { + useCase.update(clubId, 1L, UpdateBoardRequest(name = "중복 이름"), userId) + } + } + + it("공지사항 게시판의 이름을 변경하면 예외를 던진다") { + val noticeBoard = BoardTestFixture.create(id = 1L, club = club, name = "공지사항", type = BoardType.NOTICE) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns noticeBoard + + shouldThrow { + useCase.update(clubId, 1L, UpdateBoardRequest(name = "새 이름"), userId) + } + } + } + + describe("delete") { + it("게시판을 soft delete 처리하고 displayOrder를 맨 아래로 이동한다") { + val board = BoardTestFixture.create(club = club, name = "일반", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + every { boardRepository.findMaxDisplayOrderByClubId(clubId) } returns 2 + + useCase.delete(clubId, 1L, userId) + + board.isDeleted shouldBe true + board.displayOrder shouldBe 3 + verify(exactly = 0) { boardRepository.delete(any()) } + } + + it("공지사항 게시판을 삭제하면 예외를 던진다") { + val noticeBoard = BoardTestFixture.create(id = 1L, club = club, name = "공지사항", type = BoardType.NOTICE) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns noticeBoard + + shouldThrow { + useCase.delete(clubId, 1L, userId) + } + } + } + + describe("reorder") { + it("요청 순서대로 displayOrder를 저장한다") { + val board1 = + BoardTestFixture + .create( + id = 1L, + club = club, + name = "A", + type = BoardType.GENERAL, + ).also { it.reorder(0) } + val board2 = + BoardTestFixture + .create( + id = 2L, + club = club, + name = "B", + type = BoardType.GENERAL, + ).also { it.reorder(1) } + val board3 = + BoardTestFixture + .create( + id = 3L, + club = club, + name = "C", + type = BoardType.GENERAL, + ).also { it.reorder(2) } + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(board1, board2, board3) + + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(2L, 3L, 1L)), userId) + + board2.displayOrder shouldBe 0 + board3.displayOrder shouldBe 1 + board1.displayOrder shouldBe 2 + } + + it("클럽 게시판 일부만 요청해도 해당 게시판끼리 순서를 교환한다") { + val board1 = + BoardTestFixture + .create( + id = 1L, + club = club, + name = "A", + type = BoardType.GENERAL, + ).also { it.reorder(0) } + val board2 = + BoardTestFixture + .create( + id = 2L, + club = club, + name = "B", + type = BoardType.GENERAL, + ).also { it.reorder(1) } + val board3 = + BoardTestFixture + .create( + id = 3L, + club = club, + name = "C", + type = BoardType.GENERAL, + ).also { it.reorder(2) } + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(board1, board2, board3) + + // board1(0)과 board3(2)만 swap — board2는 변경 없음 + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(3L, 1L)), userId) + + board3.displayOrder shouldBe 0 + board1.displayOrder shouldBe 2 + board2.displayOrder shouldBe 1 + } + + it("다른 클럽 게시판 ID가 포함되면 예외를 던진다") { + val board1 = BoardTestFixture.create(id = 1L, club = club, name = "A", type = BoardType.GENERAL) + val board2 = BoardTestFixture.create(id = 2L, club = club, name = "B", type = BoardType.GENERAL) + // 클럽에 2개 게시판이 있는데 존재하지 않는 ID(99L) 요청 + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(board1, board2) + + shouldThrow { + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(1L, 99L)), userId) + } + } + + it("중복된 boardId가 포함되면 예외를 던진다") { + // DB 조회 전에 중복 체크로 예외 발생 → repository 호출 없음 + shouldThrow { + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(1L, 1L, 2L)), userId) + } + verify(exactly = 0) { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(any()) + } + } + + it("공지사항 ID를 요청에 포함하면 예외를 던진다") { + val noticeBoard = BoardTestFixture.create(id = 1L, club = club, name = "공지사항", type = BoardType.NOTICE) + val board2 = BoardTestFixture.create(id = 2L, club = club, name = "B", type = BoardType.GENERAL) + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(noticeBoard, board2) + + shouldThrow { + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(1L, 2L)), userId) + } + } + + it("삭제된 게시판 ID를 요청에 포함하면 예외를 던진다") { + val board1 = BoardTestFixture.create(id = 1L, club = club, name = "A", type = BoardType.GENERAL) + val deletedBoard = + BoardTestFixture + .create( + id = 2L, + club = club, + name = "B", + type = BoardType.GENERAL, + ).also { + it.markDeleted() + } + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(board1, deletedBoard) + + shouldThrow { + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(1L, 2L)), userId) + } + } + + it("요청한 게시판끼리 슬롯을 교환하고 나머지는 그대로 유지한다") { + val noticeBoard = BoardTestFixture.create(id = 1L, club = club, name = "공지사항", type = BoardType.NOTICE) + val board2 = + BoardTestFixture + .create( + id = 2L, + club = club, + name = "B", + type = BoardType.GENERAL, + ).also { it.reorder(0) } + val board3 = + BoardTestFixture + .create( + id = 3L, + club = club, + name = "C", + type = BoardType.GENERAL, + ).also { it.reorder(1) } + val board4 = + BoardTestFixture + .create( + id = 4L, + club = club, + name = "D", + type = BoardType.GENERAL, + ).also { it.reorder(2) } + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(noticeBoard, board2, board3, board4) + + // board3(1)과 board2(0)를 swap — board4는 변경 없음 + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(3L, 2L)), userId) + + board3.displayOrder shouldBe 0 + board2.displayOrder shouldBe 1 + board4.displayOrder shouldBe 2 + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCaseTest.kt new file mode 100644 index 00000000..53037af5 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCaseTest.kt @@ -0,0 +1,242 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.response.PostLikeActionResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.CategoryAccessDeniedException +import com.weeth.domain.board.application.exception.PostLikeLockTimeoutException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.repository.PostLikeRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.board.fixture.PostLikeTestFixture +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.dao.PessimisticLockingFailureException + +class ManagePostLikeUseCaseTest : + DescribeSpec({ + val postRepository = mockk() + val postLikeRepository = mockk() + val clubMemberPolicy = mockk() + val postMapper = mockk(relaxed = true) + val useCase = ManagePostLikeUseCase(postRepository, postLikeRepository, clubMemberPolicy, postMapper) + + val clubId = 1L + val userId = 10L + val postId = 100L + + val club = ClubTestFixture.createClub(id = clubId) + val board = BoardTestFixture.create(club = club) + val boardId = board.id + val member = ClubMemberTestFixture.createActiveMember(club = club) + val otherPost = + PostTestFixture.create( + board = BoardTestFixture.create(club = ClubTestFixture.createClub(id = 99L)), + ) + val privatePost = + PostTestFixture.create( + board = BoardTestFixture.create(club = club, config = BoardConfig(isPrivate = true)), + ) + val userMember = ClubMemberTestFixture.createActiveMember(club = club, memberRole = MemberRole.USER) + + beforeTest { + clearMocks(postRepository, postLikeRepository, clubMemberPolicy, postMapper) + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { postLikeRepository.save(any()) } answers { firstArg() } + every { postMapper.toLikeActionResponse(any(), any()) } answers { + PostLikeActionResponse( + boardId = firstArg().board.id, + isLiked = secondArg(), + likeCount = firstArg().likeCount, + ) + } + } + + describe("like") { + context("게시글이 존재하지 않을 때") { + it("PostNotFoundException을 던진다") { + every { postRepository.findByIdWithLock(postId) } returns null + + shouldThrow { useCase.like(clubId, boardId, postId, userId) } + } + } + + context("락 획득에 실패했을 때") { + it("PostLikeLockTimeoutException을 던진다") { + every { postRepository.findByIdWithLock(postId) } throws + PessimisticLockingFailureException("lock timeout") + + shouldThrow { useCase.like(clubId, boardId, postId, userId) } + } + } + + context("URL의 boardId와 게시글의 게시판이 다를 때") { + it("BoardNotFoundException을 던진다") { + val post = PostTestFixture.create(board = board) + every { postRepository.findByIdWithLock(postId) } returns post + + shouldThrow { useCase.like(clubId, boardId + 1, postId, userId) } + } + } + + context("다른 클럽의 게시글일 때") { + it("PostNotFoundException을 던진다") { + every { postRepository.findByIdWithLock(postId) } returns otherPost + + shouldThrow { useCase.like(clubId, boardId, postId, userId) } + } + } + + context("접근 권한이 없는 비공개 게시판일 때") { + it("CategoryAccessDeniedException을 던진다") { + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns userMember + every { postRepository.findByIdWithLock(postId) } returns privatePost + + shouldThrow { useCase.like(clubId, boardId, postId, userId) } + } + } + + context("PostLike가 없을 때") { + it("새 PostLike를 생성하고 likeCount를 증가시킨다") { + val post = PostTestFixture.create(board = board) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns null + + val result = useCase.like(clubId, boardId, postId, userId) + + result.isLiked shouldBe true + result.likeCount shouldBe 1 + verify(exactly = 1) { postLikeRepository.save(any()) } + } + } + + context("PostLike(isActive=false)가 있을 때") { + it("activate하고 likeCount를 증가시킨다") { + val post = PostTestFixture.create(board = board) + val existingLike = PostLikeTestFixture.createInactive(post = post, userId = userId) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike + + val result = useCase.like(clubId, boardId, postId, userId) + + result.isLiked shouldBe true + result.likeCount shouldBe 1 + verify(exactly = 0) { postLikeRepository.save(any()) } + } + } + + context("PostLike(isActive=true)가 이미 있을 때") { + it("no-op으로 isLiked=true를 그대로 반환한다") { + val post = PostTestFixture.create(board = board, initialLikeCount = 1) + val existingLike = PostLikeTestFixture.createActive(post = post, userId = userId) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike + + val result = useCase.like(clubId, boardId, postId, userId) + + result.isLiked shouldBe true + result.likeCount shouldBe 1 + verify(exactly = 0) { postLikeRepository.save(any()) } + } + } + } + + describe("unlike") { + context("게시글이 존재하지 않을 때") { + it("PostNotFoundException을 던진다") { + every { postRepository.findByIdWithLock(postId) } returns null + + shouldThrow { useCase.unlike(clubId, boardId, postId, userId) } + } + } + + context("락 획득에 실패했을 때") { + it("PostLikeLockTimeoutException을 던진다") { + every { postRepository.findByIdWithLock(postId) } throws + PessimisticLockingFailureException("lock timeout") + + shouldThrow { useCase.unlike(clubId, boardId, postId, userId) } + } + } + + context("URL의 boardId와 게시글의 게시판이 다를 때") { + it("BoardNotFoundException을 던진다") { + val post = PostTestFixture.create(board = board) + every { postRepository.findByIdWithLock(postId) } returns post + + shouldThrow { useCase.unlike(clubId, boardId + 1, postId, userId) } + } + } + + context("다른 클럽의 게시글일 때") { + it("PostNotFoundException을 던진다") { + every { postRepository.findByIdWithLock(postId) } returns otherPost + + shouldThrow { useCase.unlike(clubId, boardId, postId, userId) } + } + } + + context("접근 권한이 없는 비공개 게시판일 때") { + it("CategoryAccessDeniedException을 던진다") { + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns userMember + every { postRepository.findByIdWithLock(postId) } returns privatePost + + shouldThrow { useCase.unlike(clubId, boardId, postId, userId) } + } + } + + context("PostLike(isActive=true)가 있을 때") { + it("deactivate하고 likeCount를 감소시킨다") { + val post = PostTestFixture.create(board = board, initialLikeCount = 1) + val existingLike = PostLikeTestFixture.createActive(post = post, userId = userId) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike + + val result = useCase.unlike(clubId, boardId, postId, userId) + + result.isLiked shouldBe false + result.likeCount shouldBe 0 + } + } + + context("PostLike(isActive=false)가 있을 때") { + it("no-op으로 isLiked=false를 그대로 반환한다") { + val post = PostTestFixture.create(board = board) + val existingLike = PostLikeTestFixture.createInactive(post = post, userId = userId) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike + + val result = useCase.unlike(clubId, boardId, postId, userId) + + result.isLiked shouldBe false + result.likeCount shouldBe 0 + } + } + + context("PostLike가 없을 때") { + it("no-op으로 isLiked=false를 반환한다") { + val post = PostTestFixture.create(board = board) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns null + + val result = useCase.unlike(clubId, boardId, postId, userId) + + result.isLiked shouldBe false + result.likeCount shouldBe 0 + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt new file mode 100644 index 00000000..0fd0ea15 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -0,0 +1,401 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreatePostRequest +import com.weeth.domain.board.application.dto.request.UpdatePostRequest +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.CategoryAccessDeniedException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.exception.PostNotOwnedException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberCardinalTestFixture +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify + +class ManagePostUseCaseTest : + DescribeSpec({ + val postRepository = mockk() + val boardRepository = mockk() + val clubMemberPolicy = mockk(relaxed = true) + val clubMemberCardinalReader = mockk() + val fileRepository = mockk() + val fileReader = mockk() + val fileMapper = mockk() + val postMapper = mockk() + + val useCase = + ManagePostUseCase( + postRepository, + boardRepository, + clubMemberPolicy, + clubMemberCardinalReader, + fileRepository, + fileReader, + fileMapper, + postMapper, + ) + + fun createUploadedPostFile( + fileName: String, + ownerId: Long = 1L, + ): File = + File.createUploaded( + fileName = fileName, + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_$fileName", + fileSize = 10, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = ownerId, + ) + + beforeTest { + clearMocks( + postRepository, + boardRepository, + clubMemberPolicy, + clubMemberCardinalReader, + fileRepository, + fileReader, + fileMapper, + postMapper, + ) + every { postRepository.save(any()) } answers { firstArg() } + every { fileMapper.toFileList(any(), any(), any()) } returns emptyList() + every { fileRepository.saveAll(any>()) } returns emptyList() + every { fileReader.findAll(any(), any(), any()) } returns emptyList() + every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(id = 1L, boardId = 1L) + every { fileRepository.delete(any()) } just runs + every { fileRepository.flush() } just runs + every { clubMemberCardinalReader.findLatestCardinalByClubMember(any()) } returns null + } + + describe("save") { + it("일반 게시판에서 게시글을 저장한다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(10L, 1L) } returns board + + val result = useCase.save(1L, 10L, request, 1L) + + result.id shouldBe 1L + verify(exactly = 1) { postRepository.save(any()) } + } + + it("ADMIN 전용 게시판에 일반 사용자가 작성하면 예외를 던진다") { + val board = + BoardTestFixture.create( + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = MemberRole.ADMIN), + ) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(20L, 1L) } returns board + + shouldThrow { + useCase.save(1L, 20L, request, 1L) + } + + verify(exactly = 0) { postRepository.save(any()) } + } + + it("비공개 게시판에 일반 사용자가 작성하면 예외를 던진다") { + val board = + BoardTestFixture.create( + name = "비공개", + type = BoardType.GENERAL, + config = BoardConfig(isPrivate = true), + ) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(21L, 1L) } returns board + + shouldThrow { + useCase.save(1L, 21L, request, 1L) + } + + verify(exactly = 0) { postRepository.save(any()) } + } + + it("사용자의 최신 기수가 존재하면 게시글에 자동 반영된다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val cardinal = CardinalTestFixture.createCardinalInProgress(cardinalNumber = 6) + val clubMemberCardinal = ClubMemberCardinalTestFixture.create(cardinal = cardinal) + val request = CreatePostRequest(title = "게시글", content = "내용") + + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(11L, 1L) } returns board + every { clubMemberCardinalReader.findLatestCardinalByClubMember(any()) } returns clubMemberCardinal + + useCase.save(1L, 11L, request, 1L) + + verify { + postRepository.save(match { it.cardinalNumber == 6 }) + } + } + + it("사용자의 기수 정보가 없으면 cardinalNumber가 null로 저장된다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val request = CreatePostRequest(title = "게시글", content = "내용") + + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(11L, 1L) } returns board + + useCase.save(1L, 11L, request, 1L) + + verify { + postRepository.save(match { it.cardinalNumber == null }) + } + } + + it("존재하지 않는 boardId면 예외를 던진다") { + val request = CreatePostRequest(title = "제목", content = "내용") + + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(999L, 1L) } returns null + + shouldThrow { + useCase.save(1L, 999L, request, 1L) + } + } + } + + describe("update") { + it("files가 null이면 기존 파일을 유지한다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = Post.create("제목", "내용", ownerMember, board) + val request = UpdatePostRequest(title = "수정", content = "수정") + + every { postRepository.findActivePostById(1L) } returns post + + useCase.update(clubId, board.id, 1L, request, 1L) + + verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } + verify(exactly = 0) { fileRepository.saveAll(any>()) } + } + + it("files가 있으면 기존 파일을 삭제 후 교체한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) + val oldFile = createUploadedPostFile("old.png") + val newFiles = listOf(createUploadedPostFile("new.png")) + val request = + UpdatePostRequest( + title = "수정", + content = "수정", + files = + listOf( + FileSaveRequest( + "new.png", + "POST/2026-02/550e8400-e29b-41d4-a716-446655440001_new.png", + 10, + "image/png", + ), + ), + ) + + every { postRepository.findActivePostById(1L) } returns post + every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) + every { fileRepository.deleteAll(any>()) } just runs + every { fileMapper.toFileList(request.files, FileOwnerType.POST, any()) } returns newFiles + every { fileRepository.saveAll(newFiles) } returns newFiles + + useCase.update(clubId, board.id, 1L, request, 1L) + + post.title shouldBe "수정" + post.content shouldBe "수정" + verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + verify(exactly = 1) { fileRepository.saveAll(newFiles) } + } + + it("title이 null이면 기존 제목을 유지한다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = Post.create("원래 제목", "원래 내용", ownerMember, board) + val request = UpdatePostRequest(content = "수정된 내용") + + every { postRepository.findActivePostById(1L) } returns post + + useCase.update(clubId, board.id, 1L, request, 1L) + + post.title shouldBe "원래 제목" + post.content shouldBe "수정된 내용" + } + + it("content가 null이면 기존 내용을 유지한다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = Post.create("원래 제목", "원래 내용", ownerMember, board) + val request = UpdatePostRequest(title = "수정된 제목") + + every { postRepository.findActivePostById(1L) } returns post + + useCase.update(clubId, board.id, 1L, request, 1L) + + post.title shouldBe "수정된 제목" + post.content shouldBe "원래 내용" + } + + it("삭제된 Board 소속 Post를 수정하면 예외를 던진다") { + every { postRepository.findActivePostById(1L) } returns null + + shouldThrow { + useCase.update(1L, 0L, 1L, UpdatePostRequest(title = "수정"), 1L) + } + } + + it("URL의 boardId와 게시글의 게시판이 다르면 BoardNotFoundException을 던진다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id + val post = PostTestFixture.create(title = "제목", content = "내용", board = board) + + every { postRepository.findActivePostById(1L) } returns post + + shouldThrow { + useCase.update(clubId, board.id + 1, 1L, UpdatePostRequest(title = "수정"), 1L) + } + } + + it("게시판이 ADMIN 전용으로 바뀐 후 일반 사용자가 수정하면 예외를 던진다") { + val board = + BoardTestFixture.create( + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = MemberRole.ADMIN), + ) + val clubId = board.club.id + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) + + every { postRepository.findActivePostById(1L) } returns post + + shouldThrow { + useCase.update(clubId, board.id, 1L, UpdatePostRequest(title = "수정"), 1L) + } + } + + it("게시판이 비공개로 바뀐 후 일반 사용자가 수정하면 예외를 던진다") { + val board = + BoardTestFixture.create( + name = "비공개", + type = BoardType.GENERAL, + config = BoardConfig(isPrivate = true), + ) + val clubId = board.club.id + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) + + every { postRepository.findActivePostById(1L) } returns post + + shouldThrow { + useCase.update(clubId, board.id, 1L, UpdatePostRequest(title = "수정"), 1L) + } + } + } + + describe("delete") { + it("삭제 시 첨부 파일을 삭제하고 게시글을 soft delete한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) + val oldFile = createUploadedPostFile("old.png") + + every { postRepository.findActivePostById(1L) } returns post + every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) + every { fileRepository.deleteAll(any>()) } just runs + + useCase.delete(clubId, board.id, 1L, 1L) + + post.isDeleted shouldBe true + verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + verify(exactly = 0) { postRepository.delete(any()) } + } + + it("삭제된 Board 소속 Post를 삭제하면 예외를 던진다") { + every { postRepository.findActivePostById(1L) } returns null + + shouldThrow { + useCase.delete(1L, 0L, 1L, 1L) + } + } + + it("URL의 boardId와 게시글의 게시판이 다르면 BoardNotFoundException을 던진다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id + val post = PostTestFixture.create(title = "제목", content = "내용", board = board) + + every { postRepository.findActivePostById(1L) } returns post + + shouldThrow { + useCase.delete(clubId, board.id + 1, 1L, 1L) + } + } + + it("게시판이 ADMIN 전용으로 바뀐 후 일반 사용자가 삭제하면 예외를 던진다") { + val board = + BoardTestFixture.create( + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = MemberRole.ADMIN), + ) + val clubId = board.club.id + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) + + every { postRepository.findActivePostById(1L) } returns post + + shouldThrow { + useCase.delete(clubId, board.id, 1L, 1L) + } + } + } + + describe("owner validation") { + it("작성자가 아니면 수정 시 예외를 던진다") { + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) + val request = UpdatePostRequest(title = "수정", content = "수정") + + every { postRepository.findActivePostById(1L) } returns post + + shouldThrow { + useCase.update(clubId, board.id, 1L, request, 2L) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCaseTest.kt new file mode 100644 index 00000000..e0243796 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCaseTest.kt @@ -0,0 +1,122 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.exception.BoardNotInClubException +import com.weeth.domain.board.application.exception.BoardTypeMismatchException +import com.weeth.domain.board.domain.entity.LastNoticeRead +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.LastNoticeReadReader +import com.weeth.domain.board.domain.repository.LastNoticeReadRepository +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.date.shouldBeAfter +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.test.util.ReflectionTestUtils + +class MarkNoticeReadUseCaseTest : + DescribeSpec({ + val boardRepository = mockk() + val lastNoticeReadReader = mockk() + val lastNoticeReadRepository = mockk() + val userReader = mockk() + val clubMemberReader = mockk() + val clubMemberPolicy = ClubMemberPolicy(clubMemberReader) + + val useCase = + MarkNoticeReadUseCase( + boardRepository = boardRepository, + lastNoticeReadReader = lastNoticeReadReader, + lastNoticeReadRepository = lastNoticeReadRepository, + userReader = userReader, + clubMemberPolicy = clubMemberPolicy, + ) + + beforeTest { + clearMocks(boardRepository, lastNoticeReadReader, lastNoticeReadRepository, userReader, clubMemberReader) + } + + describe("execute") { + val userId = 1L + val clubId = 1L + val boardId = 1L + val user = UserTestFixture.createActiveUser1(1L) + val club = ClubTestFixture.createClub().also { ReflectionTestUtils.setField(it, "id", clubId) } + val clubMember = ClubTestFixture.createClubMember(club = club, user = user) + val noticeBoard = BoardTestFixture.createNoticeBoard(club = club) + + context("클럽 멤버가 아닌 경우") { + it("ClubMemberNotFoundException을 던진다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns null + + shouldThrow { + useCase.execute(userId, clubId, boardId) + } + } + } + + context("다른 클럽의 게시판인 경우") { + it("BoardNotInClubException을 던진다") { + val otherClub = ClubTestFixture.createClub().also { ReflectionTestUtils.setField(it, "id", 99L) } + val boardInOtherClub = BoardTestFixture.createNoticeBoard(club = otherClub) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { boardRepository.findByIdAndIsDeletedFalse(boardId) } returns boardInOtherClub + + shouldThrow { + useCase.execute(userId, clubId, boardId) + } + } + } + + context("공지 타입이 아닌 게시판인 경우") { + it("BoardTypeMismatchException을 던진다") { + val generalBoard = BoardTestFixture.create(club = club) + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { boardRepository.findByIdAndIsDeletedFalse(boardId) } returns generalBoard + + shouldThrow { + useCase.execute(userId, clubId, boardId) + } + } + } + + context("이미 읽은 기록이 있는 경우") { + it("lastReadAt을 현재 시각으로 갱신하고 새 레코드를 저장하지 않는다") { + val existing = LastNoticeRead.create(user = user, board = noticeBoard) + val beforeExecute = existing.lastReadAt + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { boardRepository.findByIdAndIsDeletedFalse(boardId) } returns noticeBoard + every { lastNoticeReadReader.findByUserIdAndBoardId(userId, boardId) } returns existing + + useCase.execute(userId, clubId, boardId) + + existing.lastReadAt shouldBeAfter beforeExecute + verify(exactly = 0) { userReader.getById(any()) } + verify(exactly = 0) { lastNoticeReadRepository.save(any()) } + } + } + + context("처음 읽는 경우") { + it("새 LastNoticeRead 레코드를 저장한다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { boardRepository.findByIdAndIsDeletedFalse(boardId) } returns noticeBoard + every { lastNoticeReadReader.findByUserIdAndBoardId(userId, boardId) } returns null + every { userReader.getById(userId) } returns user + every { lastNoticeReadRepository.save(any()) } answers { firstArg() } + + useCase.execute(userId, clubId, boardId) + + verify(exactly = 1) { userReader.getById(userId) } + verify(exactly = 1) { lastNoticeReadRepository.save(any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt new file mode 100644 index 00000000..8dd9ee78 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -0,0 +1,223 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardPostCount +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GetBoardQueryServiceTest : + DescribeSpec({ + val boardRepository = mockk() + val postRepository = mockk() + val clubMemberPolicy = mockk(relaxed = true) + val clubPermissionPolicy = mockk(relaxed = true) + val boardMapper = BoardMapper() + val queryService = + GetBoardQueryService(boardRepository, postRepository, clubMemberPolicy, clubPermissionPolicy, boardMapper) + + val clubId = 1L + val userId = 10L + + beforeTest { + clearMocks(boardRepository, postRepository, clubMemberPolicy, clubPermissionPolicy) + every { postRepository.countActivePostsByBoardIds(any()) } returns emptyList() + } + + describe("findBoards") { + it("일반 사용자에게는 공개 게시판만 반환하고 전체 게시판은 항상 포함한다") { + val noticeBoard = BoardTestFixture.create(name = "공지사항", type = BoardType.NOTICE) + val publicBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val privateBoard = + BoardTestFixture.create(name = "운영", type = BoardType.GENERAL).apply { + updateConfig(config.copy(isPrivate = true)) + } + val member = ClubMemberTestFixture.createActiveMember() + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(noticeBoard, publicBoard, privateBoard) + + val result = queryService.findBoards(clubId, userId) + + // 공지사항, 전체(가상), 일반 — 비공개 운영은 제외 + result shouldHaveSize 3 + result.map { it.name } shouldBe listOf("공지사항", "전체", "일반") + } + + it("관리자에게는 비공개 게시판도 포함하고 순서는 공지사항 → 전체 → 나머지다") { + val noticeBoard = BoardTestFixture.create(name = "공지사항", type = BoardType.NOTICE) + val publicBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val privateBoard = + BoardTestFixture.create(name = "운영", type = BoardType.GENERAL).apply { + updateConfig(config.copy(isPrivate = true)) + } + val adminMember = ClubMemberTestFixture.createAdminMember() + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns adminMember + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(noticeBoard, publicBoard, privateBoard) + + val result = queryService.findBoards(clubId, userId) + + result shouldHaveSize 4 + result.map { it.name } shouldBe listOf("공지사항", "전체", "일반", "운영") + } + + it("전체 게시판은 항상 id가 null이고 type이 ALL이다") { + val noticeBoard = BoardTestFixture.create(name = "공지사항", type = BoardType.NOTICE) + val member = ClubMemberTestFixture.createActiveMember() + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(noticeBoard) + + val result = queryService.findBoards(clubId, userId) + + val virtualAll = result.first { it.type == BoardType.ALL } + virtualAll.id shouldBe null + virtualAll.name shouldBe "전체" + } + } + + describe("findAllBoardsForAdmin") { + it("삭제된 게시판을 포함해 전체 목록을 반환한다") { + val activeBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val deletedBoard = + BoardTestFixture.create(name = "삭제됨", type = BoardType.GENERAL).apply { + markDeleted() + } + + every { boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(activeBoard, deletedBoard) + + val result = queryService.findAllBoardsForAdmin(clubId, userId) + + // 가상 전체 게시판 포함: 전체, 일반, 삭제됨 + result shouldHaveSize 3 + result.map { it.name } shouldBe listOf("전체", "일반", "삭제됨") + } + + it("활성 게시판과 비공개 게시판도 모두 포함해 반환한다") { + val publicBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val privateBoard = + BoardTestFixture.create(name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } + + every { boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(publicBoard, privateBoard) + + val result = queryService.findAllBoardsForAdmin(clubId, userId) + + // NOTICE 타입인 운영 → noticeBoards 먼저, 가상 전체 다음, 나머지 순 + result shouldHaveSize 3 + result.map { it.name } shouldBe listOf("운영", "전체", "일반") + } + + it("게시판별 활성 게시글 수를 포함해 반환한다") { + val board = BoardTestFixture.create(id = 1L, name = "일반", type = BoardType.GENERAL) + every { boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) } returns listOf(board) + every { postRepository.countActivePostsByBoardIds(listOf(board.id)) } returns + listOf(BoardPostCount(boardId = board.id, postCount = 5L)) + + val result = queryService.findAllBoardsForAdmin(clubId, userId) + + result.first().postCount shouldBe 5 + } + + it("가상 전체 게시판은 기본 설명을 포함한다") { + every { boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) } returns emptyList() + + val result = queryService.findAllBoardsForAdmin(clubId, userId) + + result.first().description shouldBe "모든 게시글을 확인할 수 있는 게시판입니다." + } + + it("게시판이 없으면 postRepository를 호출하지 않는다") { + every { boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) } returns emptyList() + + queryService.findAllBoardsForAdmin(clubId, userId) + + verify(exactly = 0) { postRepository.countActivePostsByBoardIds(any()) } + } + } + + describe("findBoardDetailForAdmin") { + it("삭제된 게시판도 조회할 수 있다") { + val deletedBoard = + BoardTestFixture.create(name = "삭제됨", type = BoardType.GENERAL).apply { + markDeleted() + } + every { boardRepository.findByIdAndClubId(3L, clubId) } returns deletedBoard + + val result = queryService.findBoardDetailForAdmin(clubId, userId, 3L) + + result.isDeleted shouldBe true + } + + it("비공개 게시판도 조회할 수 있다") { + val privateBoard = + BoardTestFixture.create(name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } + every { boardRepository.findByIdAndClubId(2L, clubId) } returns privateBoard + + val result = queryService.findBoardDetailForAdmin(clubId, userId, 2L) + + result.isPrivate shouldBe true + } + + it("존재하지 않는 boardId면 예외를 던진다") { + every { boardRepository.findByIdAndClubId(999L, clubId) } returns null + + shouldThrow { + queryService.findBoardDetailForAdmin(clubId, userId, 999L) + } + } + + it("게시글 수가 postCount에 반영된다") { + val board = BoardTestFixture.create(id = 1L, name = "일반", type = BoardType.GENERAL) + every { boardRepository.findByIdAndClubId(board.id, clubId) } returns board + every { postRepository.countActivePostsByBoardIds(listOf(board.id)) } returns + listOf(BoardPostCount(boardId = board.id, postCount = 3L)) + + val result = queryService.findBoardDetailForAdmin(clubId, userId, board.id) + + result.postCount shouldBe 3 + } + } + + describe("checkBoardNameDuplicate") { + it("같은 클럽의 활성 게시판에 같은 이름이 있으면 중복으로 반환한다") { + every { boardRepository.existsByClubIdAndNameAndIsDeletedFalse(clubId, "운영") } returns true + + val result = queryService.checkBoardNameDuplicate(clubId, userId, "운영") + + result.duplicated shouldBe true + } + + it("수정 대상 boardId가 있으면 자기 자신은 중복 검사에서 제외한다") { + every { + boardRepository.existsByClubIdAndNameAndIsDeletedFalseAndIdNot(clubId, "운영", 3L) + } returns false + + val result = queryService.checkBoardNameDuplicate(clubId, userId, "운영", 3L) + + result.duplicated shouldBe false + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt new file mode 100644 index 00000000..1b63e676 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -0,0 +1,362 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.dto.response.BoardConfigResponse +import com.weeth.domain.board.application.dto.response.PostLikeResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.NoSearchResultException +import com.weeth.domain.board.application.exception.PageNotFoundException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostLikeRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService +import com.weeth.domain.comment.domain.repository.CommentReader +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.user.application.dto.response.UserInfo +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.SliceImpl +import java.time.LocalDateTime + +class GetPostQueryServiceTest : + DescribeSpec({ + val postRepository = mockk() + val boardRepository = mockk() + val postLikeRepository = mockk() + val clubMemberPolicy = mockk(relaxed = true) + val commentReader = mockk() + val getCommentQueryService = mockk() + val fileReader = mockk() + val fileMapper = mockk() + val postMapper = mockk() + + val queryService = + GetPostQueryService( + postRepository, + boardRepository, + postLikeRepository, + clubMemberPolicy, + commentReader, + getCommentQueryService, + fileReader, + fileMapper, + postMapper, + ) + + val clubId = 1L + val userId = 1L + + beforeTest { + clearMocks( + postRepository, + boardRepository, + postLikeRepository, + clubMemberPolicy, + commentReader, + getCommentQueryService, + fileReader, + fileMapper, + postMapper, + ) + } + + describe("findPost") { + it("존재하지 않는 게시글이면 예외를 던진다") { + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns null + + shouldThrow { + queryService.findPost(clubId, userId, 0L, 1L) + } + } + + it("URL의 boardId와 게시글의 게시판이 다르면 BoardNotFoundException을 던진다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val member = ClubMemberTestFixture.createActiveMember(club = board.club) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = member, board = board) + + every { clubMemberPolicy.getActiveMember(board.club.id, userId) } returns member + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + + shouldThrow { + queryService.findPost(board.club.id, userId, board.id + 1, 1L) + } + } + + it("댓글/파일을 포함한 상세 응답을 반환한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val actualClubId = board.club.id + val member = ClubMemberTestFixture.createActiveMember(club = board.club, user = user) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = member, board = board) + val comments = listOf(mockk()) + val fileResponses = + listOf( + FileResponse( + fileId = 1L, + fileName = "a.png", + fileUrl = "https://cdn/a.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", + fileSize = 100, + contentType = "image/png", + status = FileStatus.UPLOADED, + ), + ) + val files = + listOf( + File.createUploaded( + fileName = "a.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", + fileSize = 100, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ), + ) + val detail = + com.weeth.domain.board.application.dto.response.PostDetailResponse( + id = 1L, + boardId = 1L, + boardName = "일반 게시판", + author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = MemberRole.USER), + title = "제목", + content = "내용", + time = LocalDateTime.now(), + commentCount = 1, + like = PostLikeResponse(isLiked = false, likeCount = 0), + comments = comments, + fileUrls = fileResponses, + isNew = false, + boardConfig = BoardConfigResponse(canWrite = true, canComment = true), + ) + + every { clubMemberPolicy.getActiveMember(actualClubId, userId) } returns member + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + every { commentReader.findAllByPostId(any()) } returns emptyList() + every { getCommentQueryService.toCommentTreeResponses(any()) } returns comments + every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns files + every { postLikeRepository.existsByPostAndUserIdAndIsActiveTrue(post, userId) } returns false + every { + postMapper.toDetailResponse(post, comments, fileResponses, false, any(), MemberRole.USER) + } returns detail + every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() + + val result = queryService.findPost(actualClubId, userId, board.id, 1L) + + result.id shouldBe 1L + result.comments.size shouldBe 1 + result.fileUrls.size shouldBe 1 + } + + it("비공개 게시판 게시글은 일반 멤버에게 노출하지 않는다") { + val user = UserTestFixture.createActiveUser1(1L) + val privateBoard = BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL) + val actualClubId = privateBoard.club.id + privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) + val member = ClubMemberTestFixture.createActiveMember(club = privateBoard.club, user = user) + val post = + PostTestFixture.create( + title = "제목", + content = "내용", + clubMember = member, + board = privateBoard, + ) + + every { clubMemberPolicy.getActiveMember(actualClubId, userId) } returns member + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + + shouldThrow { + queryService.findPost(actualClubId, userId, privateBoard.id, 1L) + } + } + + it("삭제된 게시판의 게시글은 조회할 수 없다") { + val user = UserTestFixture.createActiveUser1(1L) + val deletedBoard = + BoardTestFixture + .create(name = "삭제", type = BoardType.GENERAL) + .also { it.markDeleted() } + val actualClubId = deletedBoard.club.id + val member = ClubMemberTestFixture.createActiveMember(club = deletedBoard.club, user = user) + val post = + PostTestFixture.create( + title = "제목", + content = "내용", + clubMember = member, + board = deletedBoard, + ) + + every { clubMemberPolicy.getActiveMember(actualClubId, userId) } returns member + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + + shouldThrow { + queryService.findPost(actualClubId, userId, deletedBoard.id, 1L) + } + } + } + + describe("searchPosts") { + it("검색 결과가 없으면 예외를 던진다") { + val pageable = PageRequest.of(0, 10) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns board + every { postRepository.searchByBoardId(1L, "키워드", any()) } returns + SliceImpl(emptyList(), pageable, false) + + shouldThrow { + queryService.searchPosts(clubId, userId, 1L, "키워드", 0, 10) + } + } + + it("비공개 게시판은 일반 멤버가 검색할 수 없다") { + val privateBoard = BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL) + privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns privateBoard + + shouldThrow { + queryService.searchPosts(clubId, userId, 1L, "키워드", 0, 10) + } + } + } + + describe("validatePage") { + it("음수 페이지면 예외를 던진다") { + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + shouldThrow { + queryService.findPosts(clubId, userId, 1L, -1, 10) + } + } + + it("pageSize가 0이면 예외를 던진다") { + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + shouldThrow { + queryService.findPosts(clubId, userId, 1L, 0, 0) + } + } + + it("pageSize가 최대값을 초과하면 예외를 던진다") { + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + shouldThrow { + queryService.findPosts(clubId, userId, 1L, 0, 51) + } + } + } + + describe("findAllPosts") { + it("접근 가능한 게시판의 게시글을 최신순으로 반환한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val member = ClubMemberTestFixture.createActiveMember(club = board.club, user = user) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = member, board = board) + val pageable = PageRequest.of(0, 10) + val postSlice = SliceImpl(listOf(post), pageable, false) + val response = + com.weeth.domain.board.application.dto.response.PostListResponse( + id = post.id, + author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = MemberRole.USER), + boardId = board.id, + boardName = "일반", + title = "제목", + content = "내용", + time = LocalDateTime.now(), + commentCount = 0, + like = PostLikeResponse(isLiked = false, likeCount = 0), + fileUrls = emptyList(), + isNew = true, + boardConfig = BoardConfigResponse(canWrite = true, canComment = true), + ) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(board) + every { postRepository.findAllActiveByBoardIds(any(), any()) } returns postSlice + every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() + every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() + every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response + + val result = queryService.findAllPosts(clubId, userId, 0, 10) + + result.content.size shouldBe 1 + } + + it("접근 가능한 게시판이 없으면 빈 슬라이스를 반환한다") { + val board = BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL) + board.updateConfig(board.config.copy(isPrivate = true)) + val member = ClubMemberTestFixture.createActiveMember(club = board.club) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(board) + + val result = queryService.findAllPosts(clubId, userId, 0, 10) + + result.content shouldBe emptyList() + } + } + + describe("findPosts") { + it("목록 조회 시 mapper를 통해 응답으로 변환한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val member = ClubMemberTestFixture.createActiveMember(club = board.club, user = user) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = member, board = board) + val pageable = PageRequest.of(0, 10) + val postSlice = SliceImpl(listOf(post), pageable, false) + val response = + com.weeth.domain.board.application.dto.response.PostListResponse( + id = 10L, + author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = MemberRole.USER), + boardId = board.id, + boardName = "일반 게시판", + title = "제목", + content = "내용", + time = LocalDateTime.now(), + commentCount = 0, + like = PostLikeResponse(isLiked = false, likeCount = 0), + fileUrls = emptyList(), + isNew = false, + boardConfig = BoardConfigResponse(canWrite = true, canComment = true), + ) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns board + every { postRepository.findAllActiveByBoardId(1L, any()) } returns postSlice + every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() + every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() + every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response + + val result = queryService.findPosts(clubId, userId, 1L, 0, 10) + + result.content.size shouldBe 1 + verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, any>(), any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt new file mode 100644 index 00000000..cd92b628 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.board.domain.converter + +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.club.domain.enums.MemberRole +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe + +class BoardConfigConverterTest : + StringSpec({ + val converter = BoardConfigConverter() + + "BoardConfig를 JSON 문자열로 변환하고 역직렬화한다" { + val config = + BoardConfig( + commentEnabled = false, + writePermission = MemberRole.ADMIN, + isPrivate = true, + ) + + val json = converter.convertToDatabaseColumn(config) + val restored = converter.convertToEntityAttribute(json) + + restored shouldBe config + } + + "null DB 값은 null로 변환한다" { + converter.convertToEntityAttribute(null).shouldBeNull() + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt new file mode 100644 index 00000000..1bf35050 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -0,0 +1,126 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class BoardEntityTest : + StringSpec({ + "isCommentEnabled는 config 값을 반영한다" { + val board = + BoardTestFixture.create( + name = "공지사항", + type = BoardType.NOTICE, + config = BoardConfig(commentEnabled = false), + ) + + board.isCommentEnabled shouldBe false + } + + "rename은 빈 이름이면 예외를 던진다" { + val board = BoardTestFixture.create(name = "게시판", type = BoardType.GENERAL) + + shouldThrow { + board.rename(" ") + } + } + + "updateDescription은 빈 설명이면 예외를 던진다" { + val board = BoardTestFixture.create(name = "게시판", type = BoardType.GENERAL) + + shouldThrow { + board.updateDescription(" ") + } + } + + "isAdminOnly는 writePermission이 ADMIN일 때 true를 반환한다" { + val board = + BoardTestFixture.create( + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = MemberRole.ADMIN), + ) + + board.isAdminOnly shouldBe true + } + + "isAccessibleBy는 비공개 게시판을 ADMIN/LEAD에게만 허용한다" { + val privateBoard = + BoardTestFixture.create( + name = "운영", + type = BoardType.NOTICE, + config = BoardConfig(isPrivate = true), + ) + + privateBoard.isAccessibleBy(MemberRole.ADMIN) shouldBe true + privateBoard.isAccessibleBy(MemberRole.LEAD) shouldBe true + privateBoard.isAccessibleBy(MemberRole.USER) shouldBe false + } + + "canWriteBy는 비공개/관리자 전용 설정을 모두 고려한다" { + val privateBoard = + BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL, config = BoardConfig(isPrivate = true)) + val adminOnlyBoard = + BoardTestFixture.create( + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = MemberRole.ADMIN), + ) + val publicBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL, config = BoardConfig()) + + privateBoard.canWriteBy(MemberRole.USER) shouldBe false + privateBoard.canWriteBy(MemberRole.ADMIN) shouldBe true + privateBoard.canWriteBy(MemberRole.LEAD) shouldBe true + adminOnlyBoard.canWriteBy(MemberRole.USER) shouldBe false + adminOnlyBoard.canWriteBy(MemberRole.ADMIN) shouldBe true + publicBoard.canWriteBy(MemberRole.USER) shouldBe true + } + + "updateConfig는 config를 교체한다" { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val newConfig = BoardConfig(commentEnabled = false, isPrivate = true) + + board.updateConfig(newConfig) + + board.config shouldBe newConfig + } + + "markDeleted와 restore는 삭제 상태를 토글한다" { + val board = BoardTestFixture.create(name = "운영", type = BoardType.GENERAL) + + board.markDeleted() + board.isDeleted shouldBe true + + board.restore() + board.isDeleted shouldBe false + } + + "reorder는 displayOrder를 변경한다" { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + + board.reorder(3) + + board.displayOrder shouldBe 3 + } + + "reorder는 음수 순서이면 예외를 던진다" { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + + shouldThrow { + board.reorder(-1) + } + } + + "ALL 타입으로 게시판을 생성하면 예외를 던진다" { + val club = ClubTestFixture.createClub() + + shouldThrow { + Board(club = club, name = "전체", description = "전체 게시판 설명", type = BoardType.ALL) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt new file mode 100644 index 00000000..aff973cf --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt @@ -0,0 +1,74 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.board.fixture.PostTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class PostEntityTest : + StringSpec({ + "increaseCommentCount는 댓글 수를 1 증가시킨다" { + val post = PostTestFixture.create() + + post.increaseCommentCount() + + post.commentCount shouldBe 1 + } + + "decreaseCommentCount는 0이면 예외를 던진다" { + val post = PostTestFixture.create() + + shouldThrow { + post.decreaseCommentCount() + } + } + + "update는 게시글 필드를 갱신한다" { + val post = PostTestFixture.create() + + post.update( + newTitle = "변경", + newContent = "변경 내용", + ) + + post.title shouldBe "변경" + post.content shouldBe "변경 내용" + } + + "update는 content가 공백이면 예외를 던진다" { + val post = PostTestFixture.create() + + shouldThrow { + post.update( + newTitle = "변경", + newContent = " ", + ) + } + } + + "increaseLikeCount는 좋아요 수를 1 증가시킨다" { + val post = PostTestFixture.create() + + post.increaseLikeCount() + + post.likeCount shouldBe 1 + } + + "decreaseLikeCount는 0이면 예외를 던진다" { + val post = PostTestFixture.create() + + shouldThrow { + post.decreaseLikeCount() + } + } + + "markDeleted와 restore는 삭제 상태를 토글한다" { + val post = PostTestFixture.create() + + post.markDeleted() + post.isDeleted shouldBe true + + post.restore() + post.isDeleted shouldBe false + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostLikeEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostLikeEntityTest.kt new file mode 100644 index 00000000..400d9345 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostLikeEntityTest.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.board.fixture.PostLikeTestFixture +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class PostLikeEntityTest : + StringSpec({ + "초기 생성 시 isActive는 true이다" { + val like = PostLikeTestFixture.createActive() + + like.isActive shouldBe true + } + + "activate는 isActive를 true로 설정한다" { + val like = PostLikeTestFixture.createInactive() + + like.activate() + + like.isActive shouldBe true + } + + "deactivate는 isActive를 false로 설정한다" { + val like = PostLikeTestFixture.createActive() + + like.deactivate() + + like.isActive shouldBe false + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/repository/NoticeRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/repository/NoticeRepositoryTest.kt deleted file mode 100644 index fbcad847..00000000 --- a/src/test/kotlin/com/weeth/domain/board/domain/repository/NoticeRepositoryTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.weeth.domain.board.domain.repository - -import com.weeth.config.TestContainersConfig -import com.weeth.domain.board.fixture.NoticeTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.context.annotation.Import -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Sort - -@DataJpaTest -@Import(TestContainersConfig::class) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class NoticeRepositoryTest( - private val noticeRepository: NoticeRepository, -) : DescribeSpec({ - - describe("findPageBy") { - it("공지 id 내림차순으로 조회") { - val notices = - (0 until 5).map { i -> - NoticeTestFixture.createNotice(title = "공지$i") - } - noticeRepository.saveAll(notices) - - val pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "id")) - - val pagedNotices = noticeRepository.findPageBy(pageable) - - pagedNotices.size shouldBe 3 - pagedNotices.map { it.title } shouldContainExactly - listOf(notices[4].title, notices[3].title, notices[2].title) - pagedNotices.hasNext().shouldBeTrue() - } - } - - describe("search") { - it("검색어가 포함된 공지를 id 내림차순으로 조회") { - val notices = - (0 until 6).map { i -> - if (i % 2 == 0) { - NoticeTestFixture.createNotice(title = "공지$i") - } else { - NoticeTestFixture.createNotice(title = "검색$i") - } - } - noticeRepository.saveAll(notices) - - val pageable = PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "id")) - - val searchedNotices = noticeRepository.search("검색", pageable) - - searchedNotices.content shouldHaveSize 3 - searchedNotices.content.map { it.title } shouldContainExactly - listOf(notices[5].title, notices[3].title, notices[1].title) - searchedNotices.hasNext().shouldBeFalse() - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt new file mode 100644 index 00000000..5e99f2ca --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt @@ -0,0 +1,44 @@ +package com.weeth.domain.board.fixture + +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.fixture.ClubTestFixture +import org.springframework.test.util.ReflectionTestUtils + +object BoardTestFixture { + fun create( + id: Long = 0L, + club: Club = ClubTestFixture.createClub(), + name: String = "일반 게시판", + description: String = "일반 게시판 설명", + type: BoardType = BoardType.GENERAL, + config: BoardConfig = BoardConfig(), + ): Board { + val board = + Board( + club = club, + name = name, + description = description, + type = type, + config = config, + ) + if (id != 0L) ReflectionTestUtils.setField(board, "id", id) + return board + } + + fun createNoticeBoard( + club: Club = ClubTestFixture.createClub(), + name: String = "공지사항", + description: String = "운영진이 공지사항을 올리는 게시판입니다.", + ): Board = + create( + club = club, + name = name, + description = description, + type = BoardType.NOTICE, + config = BoardConfig(writePermission = MemberRole.ADMIN), + ) +} diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/NoticeTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/NoticeTestFixture.kt deleted file mode 100644 index da71720e..00000000 --- a/src/test/kotlin/com/weeth/domain/board/fixture/NoticeTestFixture.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.weeth.domain.board.fixture - -import com.weeth.domain.board.domain.entity.Notice -import com.weeth.domain.user.domain.entity.User - -object NoticeTestFixture { - fun createNotice( - id: Long? = null, - title: String, - user: User? = null, - ): Notice = - Notice - .builder() - .id(id) - .title(title) - .content("내용") - .user(user) - .comments(ArrayList()) - .commentCount(0) - .build() -} diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/PostLikeTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/PostLikeTestFixture.kt new file mode 100644 index 00000000..d217c2fa --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/fixture/PostLikeTestFixture.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.board.fixture + +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.PostLike + +object PostLikeTestFixture { + fun createActive( + post: Post = PostTestFixture.create(), + userId: Long = 1L, + ): PostLike = PostLike(post = post, userId = userId) + + fun createInactive( + post: Post = PostTestFixture.create(), + userId: Long = 1L, + ): PostLike = PostLike(post = post, userId = userId).also { it.deactivate() } +} diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt index d7662651..0b8cd245 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt @@ -1,84 +1,26 @@ package com.weeth.domain.board.fixture -import com.weeth.domain.board.application.dto.PostDTO +import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.domain.entity.enums.Part -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Role -import java.time.LocalDateTime +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.fixture.ClubMemberTestFixture object PostTestFixture { - fun createPost( - id: Long, - title: String, - category: Category, + fun create( + title: String = "게시글", + content: String = "내용", + clubMember: ClubMember = ClubMemberTestFixture.createActiveMember(), + board: Board = BoardTestFixture.create(), + cardinalNumber: Int? = null, + initialLikeCount: Int = 0, ): Post = - Post - .builder() - .id(id) - .title(title) - .content("내용") - .comments(ArrayList()) - .commentCount(0) - .category(category) - .build() - - fun createEducationPost( - id: Long, - user: User, - title: String, - category: Category, - parts: List, - cardinalNumber: Int, - week: Int, - ): Post = - Post - .builder() - .id(id) - .user(user) - .title(title) - .content("내용") - .parts(parts) - .cardinalNumber(cardinalNumber) - .week(week) - .commentCount(0) - .category(Category.Education) - .comments(ArrayList()) - .build() - - fun createResponseAll(post: Post): PostDTO.ResponseAll = - PostDTO.ResponseAll - .builder() - .id(post.id) - .part(post.part) - .role(Role.USER) - .title(post.title) - .content(post.content) - .studyName(post.studyName) - .week(post.week) - .time(LocalDateTime.now()) - .commentCount(post.commentCount) - .hasFile(false) - .isNew(false) - .build() - - fun createResponseEducationAll( - post: Post, - fileExists: Boolean, - ): PostDTO.ResponseEducationAll = - PostDTO.ResponseEducationAll - .builder() - .id(post.id) - .name(post.user.name) - .parts(post.parts) - .position(post.user.position) - .role(post.user.role) - .title(post.title) - .content(post.content) - .time(post.createdAt) - .commentCount(post.commentCount) - .hasFile(fileExists) - .isNew(false) - .build() + Post( + title = title, + content = content, + clubMember = clubMember, + board = board, + cardinalNumber = cardinalNumber, + ).also { post -> + repeat(initialLikeCount) { post.increaseLikeCount() } + } } diff --git a/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt new file mode 100644 index 00000000..631b3865 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt @@ -0,0 +1,148 @@ +package com.weeth.domain.cardinal.application.usecase.command + +import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest +import com.weeth.domain.cardinal.application.dto.response.CardinalResponse +import com.weeth.domain.cardinal.application.mapper.CardinalMapper +import com.weeth.domain.cardinal.application.usecase.query.GetCardinalQueryService +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.cardinal.domain.service.CardinalStatusPolicy +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDateTime + +class CardinalUseCaseTest : + DescribeSpec({ + val cardinalRepository = mockk() + val cardinalReader = mockk() + val cardinalMapper = mockk() + val clubReader = mockk() + val clubMemberPolicy = mockk(relaxed = true) + val clubPermissionPolicy = mockk(relaxed = true) + val cardinalStatusPolicy = CardinalStatusPolicy(cardinalRepository) + val manageCardinalUseCase = + ManageCardinalUseCase( + cardinalRepository, + cardinalMapper, + cardinalStatusPolicy, + clubReader, + clubPermissionPolicy, + ) + val getCardinalQueryService = + GetCardinalQueryService(cardinalReader, clubMemberPolicy, cardinalMapper) + + val clubId = 1L + val userId = 99L + val club = ClubTestFixture.createClub(id = clubId) + + beforeTest { + clearMocks( + cardinalRepository, + cardinalReader, + cardinalMapper, + clubReader, + clubMemberPolicy, + clubPermissionPolicy, + ) + every { clubReader.getClubById(clubId) } returns club + every { + clubMemberPolicy.getActiveMember( + clubId, + userId, + ) + } returns ClubTestFixture.createClubMember(club = club) + } + + describe("save") { + context("진행중이 아닌 기수라면") { + it("검증 후 저장만 한다") { + val request = CardinalSaveRequest(7, false) + val toSave = CardinalTestFixture.createCardinal(cardinalNumber = 7) + val saved = CardinalTestFixture.createCardinal(cardinalNumber = 7) + + every { cardinalRepository.findByClubIdAndCardinalNumber(clubId, 7) } returns null + every { cardinalMapper.toEntity(club, request) } returns toSave + every { cardinalRepository.save(toSave) } returns saved + + manageCardinalUseCase.save(clubId, request, userId) + + verify { cardinalRepository.findByClubIdAndCardinalNumber(clubId, 7) } + verify { cardinalRepository.save(toSave) } + verify(exactly = 0) { cardinalRepository.findAllInProgressByClubIdWithLock(clubId) } + } + } + + context("새 기수가 진행중이라면") { + it("기존 기수는 DONE, 현재기수는 IN_PROGRESS가 된다") { + val request = CardinalSaveRequest(7, true) + val oldCardinal = CardinalTestFixture.createCardinalInProgress(club = club, cardinalNumber = 6) + val newCardinalBeforeSave = CardinalTestFixture.createCardinal(club = club, cardinalNumber = 7) + val newCardinalAfterSave = CardinalTestFixture.createCardinal(club = club, cardinalNumber = 7) + + every { cardinalRepository.findByClubIdAndCardinalNumber(clubId, 7) } returns null + every { cardinalRepository.findAllInProgressByClubIdWithLock(clubId) } returns listOf(oldCardinal) + every { cardinalMapper.toEntity(club, request) } returns newCardinalBeforeSave + every { cardinalRepository.save(newCardinalBeforeSave) } returns newCardinalAfterSave + + manageCardinalUseCase.save(clubId, request, userId) + + verify { cardinalRepository.findAllInProgressByClubIdWithLock(clubId) } + verify { cardinalRepository.save(newCardinalBeforeSave) } + + oldCardinal.status shouldBe CardinalStatus.DONE + newCardinalAfterSave.status shouldBe CardinalStatus.IN_PROGRESS + } + } + } + + describe("activate") { + it("해당 기수를 IN_PROGRESS로 지정하고 나머지는 DONE으로 변경한다") { + val cardinal = CardinalTestFixture.createCardinal(club = club, cardinalNumber = 6) + val oldCardinal = CardinalTestFixture.createCardinalInProgress(club = club, cardinalNumber = 5) + every { cardinalRepository.findByIdAndClubId(1L, clubId) } returns cardinal + every { cardinalRepository.findAllInProgressByClubIdWithLock(clubId) } returns listOf(oldCardinal) + + manageCardinalUseCase.activate(clubId, 1L, userId) + + oldCardinal.status shouldBe CardinalStatus.DONE + cardinal.status shouldBe CardinalStatus.IN_PROGRESS + } + } + + describe("findAll") { + it("조회된 모든 기수를 DTO로 매핑한다") { + val cardinal1 = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 6) + val cardinal2 = CardinalTestFixture.createCardinalInProgress(id = 2L, cardinalNumber = 7) + val cardinals = listOf(cardinal1, cardinal2) + val now = LocalDateTime.now() + + val response1 = CardinalResponse(1L, 6, CardinalStatus.DONE, now.minusDays(5), now.minusDays(3)) + val response2 = CardinalResponse(2L, 7, CardinalStatus.IN_PROGRESS, now.minusDays(2), now) + + every { cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId) } returns cardinals + every { cardinalMapper.toResponse(cardinal1) } returns response1 + every { cardinalMapper.toResponse(cardinal2) } returns response2 + + val responses = getCardinalQueryService.findAll(clubId, userId) + + verify { cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId) } + verify(exactly = 2) { cardinalMapper.toResponse(any()) } + + responses shouldHaveSize 2 + responses.map { it.cardinalNumber } shouldBe listOf(6, 7) + responses.map { it.status } shouldBe listOf(CardinalStatus.DONE, CardinalStatus.IN_PROGRESS) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt new file mode 100644 index 00000000..a0269c7d --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.cardinal.domain.entity + +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class CardinalTest : + StringSpec({ + val club = ClubTestFixture.createClub() + + "inProgress/done 상태 전환" { + val cardinal = Cardinal(club = club, cardinalNumber = 10) + + cardinal.inProgress() + cardinal.status shouldBe CardinalStatus.IN_PROGRESS + + cardinal.done() + cardinal.status shouldBe CardinalStatus.DONE + } + }) diff --git a/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt new file mode 100644 index 00000000..9902b81b --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt @@ -0,0 +1,34 @@ +package com.weeth.domain.cardinal.domain.repository + +import com.weeth.config.TestContainersConfig +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import + +@DataJpaTest +@Import(TestContainersConfig::class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class CardinalRepositoryTest( + private val cardinalRepository: CardinalRepository, + private val clubRepository: ClubRepository, +) : StringSpec({ + + "기수번호로 조회된다" { + val club = clubRepository.save(ClubTestFixture.createClub()) + val cardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 7, + ) + cardinalRepository.save(cardinal) + + val result = cardinalRepository.findByCardinalNumber(7) + + result shouldBe cardinal + } + }) diff --git a/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt new file mode 100644 index 00000000..1bf53947 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt @@ -0,0 +1,32 @@ +package com.weeth.domain.cardinal.fixture + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.fixture.ClubTestFixture + +object CardinalTestFixture { + fun createCardinal( + id: Long? = null, + club: Club = ClubTestFixture.createClub(), + cardinalNumber: Int, + ): Cardinal = + Cardinal( + club = club, + id = id ?: 0L, + cardinalNumber = cardinalNumber, + status = CardinalStatus.DONE, + ) + + fun createCardinalInProgress( + id: Long? = null, + club: Club = ClubTestFixture.createClub(), + cardinalNumber: Int, + ): Cardinal = + Cardinal( + club = club, + id = id ?: 0L, + cardinalNumber = cardinalNumber, + status = CardinalStatus.IN_PROGRESS, + ) +} diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt new file mode 100644 index 00000000..c930f166 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt @@ -0,0 +1,592 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.attendance.fixture.AttendanceTestFixture +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest +import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberCardinalRequest +import com.weeth.domain.club.application.exception.CannotBanLeadException +import com.weeth.domain.club.application.exception.CardinalRemovalHasAttendanceException +import com.weeth.domain.club.application.exception.ClubMemberNotInClubException +import com.weeth.domain.club.application.exception.LeadSelfTransferException +import com.weeth.domain.club.application.exception.LeadTransferOnlyException +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.application.exception.NotLeadException +import com.weeth.domain.club.application.exception.SelfBanNotAllowedException +import com.weeth.domain.club.application.exception.SelfRoleChangeNotAllowedException +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.penalty.domain.repository.PenaltyReader +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.test.util.ReflectionTestUtils + +class AdminClubMemberUseCaseTest : + DescribeSpec({ + val clubMemberPolicy = mockk() + val clubPermissionPolicy = mockk() + val clubMemberCardinalPolicy = mockk(relaxed = true) + val cardinalReader = mockk(relaxed = true) + val clubMemberReader = mockk(relaxed = true) + val sessionReader = mockk(relaxed = true) + val attendanceRepository = mockk(relaxed = true) + val penaltyReader = mockk(relaxed = true) + val clubMemberCardinalRepository = mockk(relaxed = true) + val useCase = + AdminClubMemberUseCase( + clubMemberPolicy, + clubPermissionPolicy, + clubMemberCardinalPolicy, + cardinalReader, + clubMemberReader, + sessionReader, + attendanceRepository, + penaltyReader, + clubMemberCardinalRepository, + ) + val club = ClubTestFixture.createClub(id = 1L) + val adminMember = ClubMemberTestFixture.createAdminMember(club = club) + + beforeTest { + clearMocks( + clubMemberPolicy, + clubPermissionPolicy, + clubMemberCardinalPolicy, + cardinalReader, + clubMemberReader, + sessionReader, + attendanceRepository, + penaltyReader, + clubMemberCardinalRepository, + ) + every { + attendanceRepository.saveAll( + any>(), + ) + } answers + { firstArg() } + every { clubMemberCardinalRepository.save(any()) } answers { firstArg() } + } + + describe("accept") { + it("같은 동아리 소속 멤버를 승인한다") { + val member = ClubMemberTestFixture.createWaitingMember() + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + + useCase.accept(1L, 10L, 20L) + + member.memberStatus shouldBe MemberStatus.ACTIVE + } + + it("다른 동아리 소속 멤버면 예외가 발생한다") { + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } throws ClubMemberNotInClubException() + + shouldThrow { + useCase.accept(1L, 10L, 20L) + } + } + } + + describe("ban") { + it("같은 동아리 소속 멤버를 추방한다") { + ReflectionTestUtils.setField(adminMember, "id", 10L) + val member = ClubMemberTestFixture.createActiveMember(id = 20L) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getActiveMemberInClubWithLock(1L, 20L) } returns member + + useCase.ban(1L, 10L, 20L) + + member.memberStatus shouldBe MemberStatus.BANNED + } + + it("자기 자신은 추방할 수 없다") { + ReflectionTestUtils.setField(adminMember, "id", 10L) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getActiveMemberInClubWithLock(1L, 10L) } returns adminMember + + shouldThrow { + useCase.ban(1L, 10L, 10L) + } + } + + it("리더는 권한 이양 전 추방할 수 없다") { + ReflectionTestUtils.setField(adminMember, "id", 10L) + val leadMember = ClubMemberTestFixture.createLeadMember(club = club) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getActiveMemberInClubWithLock(1L, 20L) } returns leadMember + + shouldThrow { + useCase.ban(1L, 10L, 20L) + } + } + } + + describe("restore") { + it("추방된 멤버를 복구한다") { + val member = ClubMemberTestFixture.createBannedMember(club = club) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + + useCase.restore(1L, 10L, 20L) + + member.memberStatus shouldBe MemberStatus.ACTIVE + } + } + + describe("updateMemberRole") { + it("같은 동아리 소속 멤버의 권한을 변경한다") { + val member = ClubMemberTestFixture.createActiveMember(memberRole = MemberRole.USER) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + + useCase.updateMemberRole( + 1L, + 10L, + 20L, + ClubMemberRoleUpdateRequest(memberRole = MemberRole.ADMIN), + ) + + member.memberRole shouldBe MemberRole.ADMIN + } + + it("LEAD로 직접 변경 시도하면 예외가 발생한다") { + val member = ClubMemberTestFixture.createActiveMember(memberRole = MemberRole.USER) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + + shouldThrow { + useCase.updateMemberRole( + 1L, + 10L, + 20L, + ClubMemberRoleUpdateRequest(memberRole = MemberRole.LEAD), + ) + } + } + + it("LEAD 멤버의 역할을 직접 변경 시도하면 예외가 발생한다") { + val leadMember = ClubMemberTestFixture.createLeadMember() + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns leadMember + + shouldThrow { + useCase.updateMemberRole( + 1L, + 10L, + 20L, + ClubMemberRoleUpdateRequest(memberRole = MemberRole.ADMIN), + ) + } + } + + it("자기 자신의 권한은 변경할 수 없다") { + ReflectionTestUtils.setField(adminMember, "id", 10L) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 10L) } returns adminMember + + shouldThrow { + useCase.updateMemberRole( + 1L, + 10L, + 10L, + ClubMemberRoleUpdateRequest(memberRole = MemberRole.USER), + ) + } + } + } + + describe("transferLead") { + val club = ClubTestFixture.createClub() + + it("LEAD가 다른 멤버에게 권한을 이양한다") { + val lead = ClubMemberTestFixture.createLeadMember(club = club) + val target = ClubMemberTestFixture.createActiveMember(club = club) + ReflectionTestUtils.setField(lead, "id", 10L) + ReflectionTestUtils.setField(target, "id", 20L) + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns lead + every { clubMemberPolicy.getActiveMemberInClubWithLock(1L, 20L) } returns target + + useCase.transferLead(1L, 10L, 20L) + + lead.memberRole shouldBe MemberRole.ADMIN + target.memberRole shouldBe MemberRole.LEAD + } + + it("LEAD가 아닌 멤버가 이양을 시도하면 예외가 발생한다") { + val nonLead = ClubMemberTestFixture.createActiveMember(club = club) + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns nonLead + + shouldThrow { + useCase.transferLead(1L, 10L, 20L) + } + } + + it("자기 자신에게 이양을 시도하면 예외가 발생한다") { + val lead = ClubMemberTestFixture.createLeadMember(club = club) + ReflectionTestUtils.setField(lead, "id", 10L) + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns lead + every { clubMemberPolicy.getActiveMemberInClubWithLock(1L, 10L) } returns lead + + shouldThrow { + useCase.transferLead(1L, 10L, 10L) + } + } + + it("비활성 멤버에게 이양을 시도하면 예외가 발생한다") { + val lead = ClubMemberTestFixture.createLeadMember(club = club) + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns lead + every { + clubMemberPolicy.getActiveMemberInClubWithLock(1L, 20L) + } throws MemberNotActiveException() + + shouldThrow { + useCase.transferLead(1L, 10L, 20L) + } + } + + it("존재하지 않는 멤버에게 이양을 시도하면 예외가 발생한다") { + val lead = ClubMemberTestFixture.createLeadMember(club = club) + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns lead + every { + clubMemberPolicy.getActiveMemberInClubWithLock(1L, 99L) + } throws ClubMemberNotInClubException() + + shouldThrow { + useCase.transferLead(1L, 10L, 99L) + } + } + } + + describe("applyOb") { + it("새 기수를 정상 등록한다") { + val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = adminMember.club) + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = adminMember.club, + cardinalNumber = 8, + ) + val session = SessionTestFixture.createSession(club = adminMember.club, cardinal = 8) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal + every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns true + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8))) + + verify(exactly = 1) { clubMemberCardinalRepository.save(any()) } + verify( + exactly = 1, + ) { attendanceRepository.saveAll(any>()) } + } + + it("이미 등록된 기수는 무시한다") { + val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = adminMember.club) + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = adminMember.club, + cardinalNumber = 8, + ) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal + every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns false + + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8))) + + verify(exactly = 0) { clubMemberCardinalRepository.save(any()) } + verify( + exactly = 0, + ) { attendanceRepository.saveAll(any>()) } + } + + it("동일한 요청이 중복으로 전달되면 1회만 처리한다") { + val session = SessionTestFixture.createSession(club = adminMember.club, cardinal = 8) + val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = adminMember.club) + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = adminMember.club, + cardinalNumber = 8, + ) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal + every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns true + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8), ClubMemberApplyObRequest(20L, 8))) + + verify(exactly = 1) { clubMemberCardinalRepository.save(any()) } + verify( + exactly = 1, + ) { attendanceRepository.saveAll(any>()) } + } + + it("존재하지 않는 기수면 예외가 발생한다") { + val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = adminMember.club) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns null + + shouldThrow { + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8))) + } + } + + it("다른 클럽 소속 멤버 ID가 포함된 경우 예외가 발생한다") { + val otherClubMember = ClubMemberTestFixture.createActiveMember(id = 20L) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(otherClubMember) + + shouldThrow { + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8))) + } + } + + it("최신/첫 기수 등록 시 출석 통계를 초기화한다") { + val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = adminMember.club) + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = adminMember.club, + cardinalNumber = 8, + ) + repeat(2) { member.attend() } + repeat(1) { member.absent() } + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal + every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns true + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns emptyList() + + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8))) + + member.attendanceStats.attendanceCount shouldBe 0 + member.attendanceStats.absenceCount shouldBe 0 + member.attendanceStats.attendanceRate shouldBe 0 + } + } + + describe("updateCardinals") { + // 각 it에서 member를 독립 생성하여 상태 오염 방지 + fun createMember() = ClubMemberTestFixture.createActiveMember(id = 20L, club = club) + + fun stubMemberLock(member: com.weeth.domain.club.domain.entity.ClubMember) { + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberReader.findByIdWithLock(20L) } returns member + } + + it("기수를 추가하면 해당 기수의 세션에 출석이 초기화된다") { + val member = createMember() + val cardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val session = SessionTestFixture.createSession(club = club, cardinal = 8) + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(1L)) } returns listOf(cardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns emptyList() + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns false + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(1L))) + + verify( + exactly = 1, + ) { attendanceRepository.saveAll(any>()) } + } + + it("기수를 추가할 때 세션이 없으면 출석 초기화를 하지 않는다") { + val member = createMember() + val cardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(1L)) } returns listOf(cardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns emptyList() + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns false + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns emptyList() + + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(1L))) + + verify( + exactly = 0, + ) { attendanceRepository.saveAll(any>()) } + } + + it("최신 기수를 새로 추가하면 출석 통계와 패널티가 리셋된다") { + val member = + createMember().also { + repeat(3) { _ -> it.attend() } + it.incrementPenaltyCount() + } + val existingCardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val newCardinal = CardinalTestFixture.createCardinal(id = 2L, club = club, cardinalNumber = 9) + val existingLink = ClubMemberCardinal.create(member, existingCardinal) + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(1L, 2L)) } returns + listOf(existingCardinal, newCardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns listOf(existingLink) + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, newCardinal) } returns true + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(9)) } returns emptyList() + + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(1L, 2L))) + + member.attendanceStats.attendanceCount shouldBe 0 + member.attendanceStats.absenceCount shouldBe 0 + member.penaltyCount shouldBe 0 + } + + it("출석 기록 없는 기수 삭제 시 force 없이도 바로 삭제된다") { + val member = createMember() + // 현재: 8기, 9기 보유 → 요청: 9기만 유지 → 8기 삭제 + val keepCardinal = CardinalTestFixture.createCardinal(id = 2L, club = club, cardinalNumber = 9) + val removeCardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val keepLink = ClubMemberCardinal.create(member, keepCardinal) + val removeLink = ClubMemberCardinal.create(member, removeCardinal) + val session = SessionTestFixture.createSession(club = club, cardinal = 8) + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(2L)) } returns listOf(keepCardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns + listOf(keepLink, removeLink) + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + every { attendanceRepository.findAllByClubMemberAndSessionIn(member, listOf(session)) } returns + emptyList() + every { attendanceRepository.findAllByClubMemberIdAndCardinal(20L, 9) } returns emptyList() + every { penaltyReader.countByClubMemberIdAndCardinalId(20L, 2L) } returns 0 + + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(2L))) + + member.penaltyCount shouldBe 0 + verify(exactly = 1) { clubMemberCardinalRepository.deleteAll(listOf(removeLink)) } + } + + it("출석/결석 기록이 있는 기수 삭제 시 force=false면 예외가 발생한다") { + val member = createMember() + // 현재: 8기, 9기 보유 → 요청: 9기만 유지 → 8기 삭제 + val keepCardinal = CardinalTestFixture.createCardinal(id = 2L, club = club, cardinalNumber = 9) + val removeCardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val keepLink = ClubMemberCardinal.create(member, keepCardinal) + val removeLink = ClubMemberCardinal.create(member, removeCardinal) + val session = SessionTestFixture.createSession(club = club, cardinal = 8) + val attendance = AttendanceTestFixture.createAttendance(session, member).also { it.attend() } + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(2L)) } returns listOf(keepCardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns + listOf(keepLink, removeLink) + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + every { attendanceRepository.findAllByClubMemberAndSessionIn(member, listOf(session)) } returns + listOf(attendance) + + shouldThrow { + useCase.updateCardinals( + 1L, + 10L, + 20L, + UpdateMemberCardinalRequest(cardinalIds = listOf(2L), force = false), + ) + } + + verify(exactly = 0) { + attendanceRepository.deleteAll(any>()) + } + verify(exactly = 0) { clubMemberCardinalRepository.deleteAll(any()) } + } + + it("출석/결석 기록이 있는 기수 삭제 시 force=true면 남은 출석 기록 기준으로 통계가 재계산된다") { + val member = createMember() + // 현재: 8기, 9기 보유 → 요청: 8기만 유지 → 9기 삭제 + val keepCardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val removeCardinal = CardinalTestFixture.createCardinal(id = 2L, club = club, cardinalNumber = 9) + val keepLink = ClubMemberCardinal.create(member, keepCardinal) + val removeLink = ClubMemberCardinal.create(member, removeCardinal) + val session8 = SessionTestFixture.createSession(club = club, cardinal = 8) + val session9 = SessionTestFixture.createSession(club = club, cardinal = 9) + val removeAttendance = AttendanceTestFixture.createAttendance(session9, member).also { it.attend() } + val remainingAttendance = AttendanceTestFixture.createAttendance(session8, member).also { it.attend() } + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(1L)) } returns listOf(keepCardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns + listOf(keepLink, removeLink) + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(9)) } returns listOf(session9) + every { attendanceRepository.findAllByClubMemberAndSessionIn(member, listOf(session9)) } returns + listOf(removeAttendance) + every { attendanceRepository.findAllByClubMemberIdAndCardinal(20L, 8) } returns + listOf(remainingAttendance) + every { penaltyReader.countByClubMemberIdAndCardinalId(20L, 1L) } returns 2 + + useCase.updateCardinals( + 1L, + 10L, + 20L, + UpdateMemberCardinalRequest(cardinalIds = listOf(1L), force = true), + ) + + // 9기 제거 후 남은 출석(8기 1건) 기준으로 통계 재계산, 패널티도 8기 기준으로 복구 + member.attendanceStats.attendanceCount shouldBe 1 + member.penaltyCount shouldBe 2 + verify(exactly = 1) { attendanceRepository.deleteAll(listOf(removeAttendance)) } + verify(exactly = 1) { clubMemberCardinalRepository.deleteAll(listOf(removeLink)) } + } + + it("모든 기수 제거 시 출석 통계와 패널티가 0으로 초기화된다") { + val member = createMember() + val removeCardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val removeLink = ClubMemberCardinal.create(member, removeCardinal) + val session = SessionTestFixture.createSession(club = club, cardinal = 8) + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, emptyList()) } returns emptyList() + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns listOf(removeLink) + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + every { attendanceRepository.findAllByClubMemberAndSessionIn(member, listOf(session)) } returns + emptyList() + + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = emptyList())) + + member.attendanceStats.attendanceCount shouldBe 0 + member.attendanceStats.absenceCount shouldBe 0 + member.penaltyCount shouldBe 0 + } + + it("존재하지 않는 기수 ID가 포함되면 예외가 발생한다") { + val member = createMember() + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(999L)) } returns emptyList() + + shouldThrow { + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(999L))) + } + } + + it("다른 동아리 소속 멤버면 예외가 발생한다") { + val otherClubMember = ClubMemberTestFixture.createActiveMember(id = 20L) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberReader.findByIdWithLock(20L) } returns otherClubMember + + shouldThrow { + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(1L))) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt new file mode 100644 index 00000000..ed68f392 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -0,0 +1,425 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.application.dto.request.ClubJoinRequest +import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberProfileRequest +import com.weeth.domain.club.application.exception.CannotLeaveAsLeadException +import com.weeth.domain.club.application.exception.CardinalAlreadySetException +import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.domain.service.ClubJoinPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.file.fixture.FileTestFixture +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify + +class ManageClubMemberUseCaseTest : + DescribeSpec({ + val clubRepository = mockk() + val clubMemberRepository = mockk() + val clubMemberCardinalRepository = mockk(relaxed = true) + val cardinalReader = mockk() + val sessionReader = mockk() + val attendanceRepository = mockk(relaxed = true) + val userReader = mockk() + val clubMemberPolicy = mockk() + val clubJoinPolicy = mockk() + val fileRepository = mockk() + val fileAccessUrlPort = mockk() + + val useCase = + ManageClubMemberUsecase( + clubRepository = clubRepository, + clubMemberRepository = clubMemberRepository, + clubMemberCardinalRepository = clubMemberCardinalRepository, + cardinalReader = cardinalReader, + sessionReader = sessionReader, + attendanceRepository = attendanceRepository, + userReader = userReader, + clubMemberPolicy = clubMemberPolicy, + clubJoinPolicy = clubJoinPolicy, + fileRepository = fileRepository, + fileAccessUrlPort = fileAccessUrlPort, + ) + + beforeTest { + clearMocks( + clubRepository, + clubMemberRepository, + clubMemberCardinalRepository, + cardinalReader, + sessionReader, + attendanceRepository, + userReader, + clubMemberPolicy, + clubJoinPolicy, + fileRepository, + fileAccessUrlPort, + ) + every { clubMemberRepository.save(any()) } answers { firstArg() } + every { fileRepository.save(any()) } answers { firstArg() } + } + + describe("updateProfile") { + val userId = 10L + val profileImageRequest = + FileSaveRequest( + fileName = "profile.png", + storageKey = "CLUB_MEMBER_PROFILE/2026-03/00000000-0000-0000-0000-000000000000_profile.png", + fileSize = 102400L, + contentType = "image/png", + ) + + context("프로필 사진만 변경할 때") { + it("모든 활성 ClubMember의 기존 파일을 삭제하고 새 파일로 URL을 업데이트한다") { + val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) + val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) + val existingFile = + FileTestFixture.createFile( + id = 1L, + fileName = "old.png", + ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, + ownerId = userId, + ) + every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_MEMBER_PROFILE, + userId, + FileStatus.UPLOADED, + ) + } returns listOf(existingFile) + every { fileRepository.deleteAll(any>()) } returns + Unit + useCase.updateProfile(userId, UpdateMemberProfileRequest(profileImage = profileImageRequest)) + + member1.profileImageStorageKey shouldBe profileImageRequest.storageKey + member2.profileImageStorageKey shouldBe profileImageRequest.storageKey + verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { fileRepository.save(any()) } + } + } + + context("bio만 변경할 때") { + it("모든 활성 ClubMember의 bio를 업데이트하고 파일 관련 작업은 수행하지 않는다") { + val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) + val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) + + every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + + useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "안녕하세요!")) + + member1.bio shouldBe "안녕하세요!" + member2.bio shouldBe "안녕하세요!" + verify(exactly = 0) { fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) } + verify(exactly = 0) { fileRepository.save(any()) } + } + } + + context("bio를 빈 문자열로 보낼 때") { + it("모든 활성 ClubMember의 bio가 null로 저장된다") { + val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) + val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) + + every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + + useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "")) + + member1.bio shouldBe null + member2.bio shouldBe null + } + } + + context("활성 동아리 멤버십이 없을 때") { + it("ClubMemberNotFoundException을 던진다") { + every { clubMemberRepository.findActiveByUserId(userId) } returns emptyList() + + shouldThrow { + useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "안녕하세요!")) + } + } + } + } + + describe("deleteProfileImage") { + val userId = 10L + + context("활성 멤버가 프로필 사진을 삭제할 때") { + it("모든 활성 ClubMember의 파일을 삭제하고 URL을 null로 만든다") { + val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) + val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) + member1.updateProfileImageUrl("CLUB_MEMBER_PROFILE/2026-02/uuid_profile.png") + member2.updateProfileImageUrl("CLUB_MEMBER_PROFILE/2026-02/uuid_profile.png") + val existingFile = + FileTestFixture.createFile( + id = 1L, + fileName = "profile.png", + ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, + ownerId = userId, + ) + + every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_MEMBER_PROFILE, + userId, + FileStatus.UPLOADED, + ) + } returns listOf(existingFile) + every { fileRepository.deleteAll(any>()) } returns + Unit + + useCase.deleteProfileImage(userId) + + verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + member1.profileImageStorageKey shouldBe null + member2.profileImageStorageKey shouldBe null + } + } + + context("활성 동아리 멤버십이 없을 때") { + it("ClubMemberNotFoundException을 던진다") { + every { clubMemberRepository.findActiveByUserId(userId) } returns emptyList() + + shouldThrow { + useCase.deleteProfileImage(userId) + } + } + } + } + + describe("setInitialCardinals") { + val club = ClubTestFixture.createClub() + val member = ClubMemberTestFixture.createActiveMember(club = club) + + context("복수 기수를 최초 설정하는 경우") { + it("요청 기수 수만큼 ClubMemberCardinal이 저장되고, 각 기수의 세션에 출석이 초기화된다") { + val cardinal30 = + CardinalTestFixture.createCardinal( + id = 1L, + club = club, + cardinalNumber = 30, + ) + val cardinal31 = + CardinalTestFixture.createCardinal( + id = 2L, + club = club, + cardinalNumber = 31, + ) + val session30 = SessionTestFixture.createSession(club = club, cardinal = 30) + val session31 = SessionTestFixture.createSession(club = club, cardinal = 31) + + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + every { clubMemberCardinalRepository.existsByClubMember(member) } returns false + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 30) } returns cardinal30 + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 31) } returns cardinal31 + every { clubMemberCardinalRepository.saveAll(any>()) } answers + { firstArg() } + every { + sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(30, 31)) + } returns listOf(session30, session31) + + useCase.setInitialCardinals(1L, 10L, ClubMemberCardinalSetRequest(cardinals = listOf(30, 31))) + + verify(exactly = 1) { + clubMemberCardinalRepository.saveAll( + match> { + it.size == + 2 + }, + ) + } + verify(exactly = 1) { + attendanceRepository.saveAll( + match> { + it.size == + 2 + }, + ) + } + } + } + + context("세션이 없는 기수를 설정하는 경우") { + it("ClubMemberCardinal만 저장되고 출석은 초기화되지 않는다") { + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = club, + cardinalNumber = 30, + ) + + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + every { clubMemberCardinalRepository.existsByClubMember(member) } returns false + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 30) } returns cardinal + every { clubMemberCardinalRepository.saveAll(any>()) } answers + { firstArg() } + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(30)) } returns emptyList() + + useCase.setInitialCardinals(1L, 10L, ClubMemberCardinalSetRequest(cardinals = listOf(30))) + + verify(exactly = 1) { + clubMemberCardinalRepository.saveAll( + match> { + it.size == + 1 + }, + ) + } + verify( + exactly = 0, + ) { + attendanceRepository.saveAll( + any>(), + ) + } + } + } + + context("요청에 중복 기수가 포함된 경우") { + it("중복을 제거하고 1개만 저장한다") { + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = club, + cardinalNumber = 30, + ) + + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + every { clubMemberCardinalRepository.existsByClubMember(member) } returns false + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 30) } returns cardinal + every { clubMemberCardinalRepository.saveAll(any>()) } answers + { firstArg() } + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(30)) } returns emptyList() + + useCase.setInitialCardinals(1L, 10L, ClubMemberCardinalSetRequest(cardinals = listOf(30, 30))) + + verify(exactly = 1) { + clubMemberCardinalRepository.saveAll( + match> { + it.size == + 1 + }, + ) + } + } + } + + context("이미 기수가 설정된 멤버가 재설정을 시도하는 경우") { + it("CardinalAlreadySetException이 발생한다") { + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + every { clubMemberCardinalRepository.existsByClubMember(member) } returns true + + shouldThrow { + useCase.setInitialCardinals(1L, 10L, ClubMemberCardinalSetRequest(cardinals = listOf(31))) + } + + verify(exactly = 0) { clubMemberCardinalRepository.saveAll(any>()) } + } + } + + context("존재하지 않는 기수를 요청하는 경우") { + it("CardinalNotFoundException이 발생한다") { + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + every { clubMemberCardinalRepository.existsByClubMember(member) } returns false + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 99) } returns null + + shouldThrow { + useCase.setInitialCardinals(1L, 10L, ClubMemberCardinalSetRequest(cardinals = listOf(99))) + } + + verify(exactly = 0) { clubMemberCardinalRepository.saveAll(any>()) } + } + } + } + + describe("leave") { + it("LEAD 멤버가 탈퇴를 시도하면 예외가 발생한다") { + val leadMember = ClubMemberTestFixture.createLeadMember() + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns leadMember + + shouldThrow { + useCase.leave(1L, 10L) + } + } + + it("일반 멤버가 탈퇴하면 LEFT 상태로 전환된다") { + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + + useCase.leave(1L, 10L) + + member.memberStatus shouldBe MemberStatus.LEFT + } + } + + describe("join") { + context("이미 USER로 1개 동아리에 가입한 사용자가 가입 시도하는 경우") { + it("ClubJoinLimitExceededException이 발생한다") { + val targetClub = ClubTestFixture.createClub(code = "JOIN-CODE") + val user = UserTestFixture.createActiveUser1() + + every { clubRepository.getClubById(1L) } returns targetClub + every { userReader.getByIdWithLock(10L) } returns user + every { clubMemberRepository.findByClubIdAndUserId(1L, 10L) } returns null + every { clubJoinPolicy.validateJoinLimit(10L) } throws ClubJoinLimitExceededException() + + shouldThrow { + useCase.join( + clubId = 1L, + userId = 10L, + request = ClubJoinRequest(code = "JOIN-CODE"), + ) + } + + verify(exactly = 0) { clubMemberRepository.save(any()) } + } + } + + context("LEAD로 1개 동아리를 생성한 사용자가 USER로 가입 시도하는 경우") { + it("역할이 다르므로 가입에 성공한다") { + val targetClub = ClubTestFixture.createClub(code = "JOIN-CODE") + val user = UserTestFixture.createActiveUser1() + + every { clubRepository.getClubById(1L) } returns targetClub + every { userReader.getByIdWithLock(10L) } returns user + every { clubMemberRepository.findByClubIdAndUserId(1L, 10L) } returns null + justRun { clubJoinPolicy.validateJoinLimit(10L) } + + useCase.join( + clubId = 1L, + userId = 10L, + request = ClubJoinRequest(code = "JOIN-CODE"), + ) + + verify(exactly = 1) { clubMemberRepository.save(any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt new file mode 100644 index 00000000..3fb76e1b --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -0,0 +1,548 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.club.application.dto.request.ClubCreateRequest +import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.application.dto.response.ClubCreateResponse +import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException +import com.weeth.domain.club.application.exception.DuplicateClubException +import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.domain.service.ClubJoinPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify + +class ManageClubUseCaseTest : + DescribeSpec({ + val clubRepository = mockk() + val clubMemberRepository = mockk() + val cardinalRepository = mockk() + val clubMemberCardinalRepository = mockk() + val boardRepository = mockk() + val userReader = mockk() + val clubJoinPolicy = mockk() + val clubPermissionPolicy = mockk() + val fileRepository = mockk() + val clubMapper = mockk() + val useCase = + ManageClubUseCase( + clubRepository, + clubMemberRepository, + cardinalRepository, + clubMemberCardinalRepository, + boardRepository, + userReader, + clubJoinPolicy, + clubPermissionPolicy, + fileRepository, + clubMapper, + ) + val adminMember = + com.weeth.domain.club.fixture.ClubMemberTestFixture + .createAdminMember() + + beforeTest { + clearMocks( + clubRepository, + clubMemberRepository, + cardinalRepository, + clubMemberCardinalRepository, + boardRepository, + userReader, + clubJoinPolicy, + clubPermissionPolicy, + fileRepository, + clubMapper, + ) + every { clubRepository.save(any()) } answers { firstArg() } + every { clubMemberRepository.save(any()) } answers { firstArg() } + every { cardinalRepository.saveAll(any>()) } answers { firstArg() } + every { clubMemberCardinalRepository.save(any()) } answers { firstArg() } + every { boardRepository.save(any()) } answers { firstArg() } + every { clubJoinPolicy.validateCreateLimit(any()) } just Runs + every { clubRepository.existsBySchoolNameAndName(any(), any()) } returns false + every { clubMapper.toCreateResponse(any()) } returns ClubCreateResponse(clubId = "testId", clubName = "테스트") + every { fileRepository.save(any()) } answers { firstArg() } + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) + } returns emptyList() + } + + describe("create") { + val user = UserTestFixture.createActiveUser1() + + context("N기 동아리를 개설하는 경우") { + it("1기부터 N기까지 Cardinal이 생성되며, 마지막 기수만 IN_PROGRESS이다") { + val cardinalSlot = slot>() + every { userReader.getByIdWithLock(10L) } returns user + every { cardinalRepository.saveAll(capture(cardinalSlot)) } answers { firstArg() } + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 3, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + contactEmail = "test@example.com", + ), + ) + + val cardinals = cardinalSlot.captured + cardinals.size shouldBe 3 + cardinals[0].cardinalNumber shouldBe 1 + cardinals[0].status shouldBe CardinalStatus.DONE + cardinals[1].cardinalNumber shouldBe 2 + cardinals[1].status shouldBe CardinalStatus.DONE + cardinals[2].cardinalNumber shouldBe 3 + cardinals[2].status shouldBe CardinalStatus.IN_PROGRESS + } + + it("LEAD 멤버가 최신 기수에 ClubMemberCardinal로 배정된다") { + every { userReader.getByIdWithLock(10L) } returns user + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 3, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + contactEmail = "test@example.com", + ), + ) + + verify(exactly = 1) { clubMemberCardinalRepository.save(any()) } + } + + it("1기만 있는 동아리 개설 시 Cardinal 1개가 IN_PROGRESS로 생성된다") { + val cardinalSlot = slot>() + every { userReader.getByIdWithLock(10L) } returns user + every { cardinalRepository.saveAll(capture(cardinalSlot)) } answers { firstArg() } + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + contactEmail = "test@example.com", + ), + ) + + val cardinals = cardinalSlot.captured + cardinals.size shouldBe 1 + cardinals[0].cardinalNumber shouldBe 1 + cardinals[0].status shouldBe CardinalStatus.IN_PROGRESS + } + } + + it("클럽 생성 시 공지사항 게시판이 displayOrder=0으로 자동 생성된다") { + every { userReader.getByIdWithLock(10L) } returns user + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + + verify(exactly = 1) { + boardRepository.save( + match { board -> + board.type == BoardType.NOTICE && + board.name == "공지사항" && + board.displayOrder == 0 + }, + ) + } + } + + context("이미지와 함께 동아리를 개설하는 경우") { + it("프로필/배경 이미지에 대한 File 레코드가 각각 생성된다") { + every { userReader.getByIdWithLock(10L) } returns user + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + profileImage = + FileSaveRequest( + fileName = "profile.png", + storageKey = "CLUB_PROFILE/2026-03/550e8400-e29b-41d4-a716-446655440000_pf.png", + fileSize = 1024, + contentType = "image/png", + ), + backgroundImage = + FileSaveRequest( + fileName = "bg.png", + storageKey = "CLUB_BACKGROUND/2026-03/550e8400-e29b-41d4-a716-446655440001_bg.png", + fileSize = 2048, + contentType = "image/png", + ), + ), + ) + + verify(exactly = 2) { fileRepository.save(any()) } + } + + it("이미지 없이 개설하면 File 레코드가 생성되지 않는다") { + every { userReader.getByIdWithLock(10L) } returns user + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + + verify(exactly = 0) { fileRepository.save(any()) } + } + } + + context("동일 학교에 같은 이름의 동아리가 이미 존재하는 경우") { + it("DuplicateClubException이 발생하고, 이후 로직이 실행되지 않는다") { + every { userReader.getByIdWithLock(10L) } returns user + every { clubRepository.existsBySchoolNameAndName("가천대", "테스트") } returns true + + shouldThrow { + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + } + + verify(exactly = 0) { clubRepository.save(any()) } + verify(exactly = 0) { clubMemberRepository.save(any()) } + } + } + + context("이미 LEAD로 1개 동아리를 생성한 사용자가 생성 시도하는 경우") { + it("ClubCreateLimitExceededException이 발생하고, 이후 로직이 실행되지 않는다") { + every { userReader.getByIdWithLock(13L) } returns user + every { clubJoinPolicy.validateCreateLimit(13L) } throws ClubCreateLimitExceededException() + + shouldThrow { + useCase.create( + 13L, + ClubCreateRequest( + name = "새 동아리", + schoolName = "가천대학교", + description = "소개", + currentCardinal = 3, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + } + + verify(exactly = 1) { userReader.getByIdWithLock(13L) } + verify(exactly = 1) { clubJoinPolicy.validateCreateLimit(13L) } + verify(exactly = 0) { clubRepository.save(any()) } + verify(exactly = 0) { clubMemberRepository.save(any()) } + verify(exactly = 0) { cardinalRepository.saveAll(any>()) } + } + } + } + + describe("update") { + it("null 필드는 유지하고 전달된 필드만 수정한다") { + val club = + ClubTestFixture.createClub( + name = "기존 동아리", + schoolName = "가천대학교", + description = "기존 소개", + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), + ) + club.update( + null, + null, + null, + null, + null, + null, + "CLUB_PROFILE/2026-02/uuid_profile.png", + "CLUB_BACKGROUND/2026-02/uuid_background.png", + ) + + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + + useCase.update( + 1L, + 10L, + ClubUpdateRequest( + schoolName = "연세대학교", + contactPhoneNumber = "01099998888", + ), + ) + + club.name shouldBe "기존 동아리" + club.schoolName shouldBe "연세대학교" + club.description shouldBe "기존 소개" + club.clubContact.email shouldBe "club@example.com" + club.clubContact.phoneNumber shouldBe "01099998888" + club.profileImageStorageKey shouldBe "CLUB_PROFILE/2026-02/uuid_profile.png" + club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" + } + + it("프로필 이미지를 변경하면 기존 File이 삭제되고 새 File이 생성된다") { + val existingFile = mockk(relaxed = true) + val club = + ClubTestFixture.createClub( + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), + ) + + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_PROFILE, + 1L, + FileStatus.UPLOADED, + ) + } returns listOf(existingFile) + every { fileRepository.deleteAll(any>()) } just Runs + + useCase.update( + 1L, + 10L, + ClubUpdateRequest( + profileImage = + FileSaveRequest( + fileName = "new_profile.png", + storageKey = "CLUB_PROFILE/2026-03/550e8400-e29b-41d4-a716-446655440002_new.png", + fileSize = 1024, + contentType = "image/png", + ), + ), + ) + + verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { fileRepository.save(any()) } + club.profileImageStorageKey shouldBe "CLUB_PROFILE/2026-03/550e8400-e29b-41d4-a716-446655440002_new.png" + } + + it("이미지 필드가 null이면 File 관련 작업이 실행되지 않는다") { + val club = + ClubTestFixture.createClub( + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + + useCase.update(1L, 10L, ClubUpdateRequest(name = "새 이름")) + + verify(exactly = 0) { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) + } + verify(exactly = 0) { fileRepository.save(any()) } + } + + it("모든 필드가 null이면 기존 값이 유지된다") { + val club = + ClubTestFixture.createClub( + description = "기존 소개", + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + + useCase.update(1L, 10L, ClubUpdateRequest()) + + club.name shouldBe "테스트 동아리" + club.schoolName shouldBe "가천대학교" + club.description shouldBe "기존 소개" + club.clubContact.email shouldBe "club@example.com" + club.clubContact.phoneNumber shouldBe "01000000000" + } + } + + describe("deleteProfileImage") { + it("프로필 사진만 삭제하고 배경 사진은 유지한다") { + val club = + ClubTestFixture.createClub( + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), + ) + club.update( + null, + null, + null, + null, + null, + null, + "CLUB_PROFILE/2026-02/uuid_profile.png", + "CLUB_BACKGROUND/2026-02/uuid_background.png", + ) + + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + + useCase.deleteProfileImage(1L, 10L) + + club.profileImageStorageKey shouldBe null + club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" + } + + it("기존 File 레코드가 삭제된다") { + val existingFile = mockk(relaxed = true) + val club = + ClubTestFixture.createClub( + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), + ) + + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_PROFILE, + 1L, + FileStatus.UPLOADED, + ) + } returns listOf(existingFile) + every { fileRepository.deleteAll(any>()) } just Runs + + useCase.deleteProfileImage(1L, 10L) + + verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + } + } + + describe("deleteBackgroundImage") { + it("배경 사진만 삭제하고 프로필 사진은 유지한다") { + val club = + ClubTestFixture.createClub( + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), + ) + club.update( + null, + null, + null, + null, + null, + null, + "CLUB_PROFILE/2026-02/uuid_profile.png", + "CLUB_BACKGROUND/2026-02/uuid_background.png", + ) + + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + + useCase.deleteBackgroundImage(1L, 10L) + + club.profileImageStorageKey shouldBe "CLUB_PROFILE/2026-02/uuid_profile.png" + club.backgroundImageStorageKey shouldBe null + } + + it("기존 File 레코드가 삭제된다") { + val existingFile = mockk(relaxed = true) + val club = + ClubTestFixture.createClub( + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), + ) + + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_BACKGROUND, + 1L, + FileStatus.UPLOADED, + ) + } returns listOf(existingFile) + every { fileRepository.deleteAll(any>()) } just Runs + + useCase.deleteBackgroundImage(1L, 10L) + + verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt new file mode 100644 index 00000000..65f2e975 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt @@ -0,0 +1,167 @@ +package com.weeth.domain.club.application.usecase.query + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GetClubMemberQueryServiceTest : + DescribeSpec({ + val clubMemberReader = mockk() + val clubMemberCardinalReader = mockk() + val clubMemberPolicy = mockk() + val clubPermissionPolicy = mockk() + val fileAccessUrlPort = mockk() + val userReader = mockk() + val clubMapper = ClubMapper(fileAccessUrlPort) + + val service = + GetClubMemberQueryService( + clubMemberReader = clubMemberReader, + clubMemberCardinalReader = clubMemberCardinalReader, + clubMemberPolicy = clubMemberPolicy, + clubPermissionPolicy = clubPermissionPolicy, + clubMapper = clubMapper, + userReader = userReader, + ) + + beforeTest { + clearMocks(clubMemberReader, clubMemberCardinalReader, clubMemberPolicy, clubPermissionPolicy, userReader) + } + + describe("findClubMembersForAdmin") { + context("관리자가 멤버 목록을 조회하는 경우") { + it("각 멤버의 소속 기수 정보를 함께 반환한다") { + val club = ClubTestFixture.createClub() + val admin = ClubTestFixture.createClubMember(club = club, memberRole = MemberRole.ADMIN) + val member = + ClubTestFixture.createClubMember(club = club, user = UserTestFixture.createActiveUser1(1L)) + val cardinal7 = Cardinal.create(club = club, cardinalNumber = 7) + val cardinal6 = Cardinal.create(club = club, cardinalNumber = 6) + val memberCardinals = + listOf( + ClubMemberCardinal.create(member, cardinal7), + ClubMemberCardinal.create(member, cardinal6), + ) + + every { clubPermissionPolicy.requireAdmin(1L, 99L) } returns admin + every { clubMemberReader.findAllByClubId(1L) } returns listOf(member) + every { clubMemberCardinalReader.findAllByClubMembers(listOf(member)) } returns memberCardinals + + val result = service.findClubMembersForAdmin(clubId = 1L, userId = 99L) + + result shouldHaveSize 1 + val response = result.first() + response.name shouldBe member.user.name + response.email shouldBe member.user.emailValue + response.studentId shouldBe member.user.studentId + response.tel shouldBe member.user.telValue + response.department shouldBe member.user.department + response.memberStatus shouldBe member.memberStatus + response.memberRole shouldBe member.memberRole + response.attendanceCount shouldBe member.attendanceStats.attendanceCount + response.absenceCount shouldBe member.attendanceStats.absenceCount + response.attendanceRate shouldBe member.attendanceStats.attendanceRate + response.penaltyCount shouldBe member.penaltyCount + response.cardinals shouldBe listOf(6, 7) + verify(exactly = 1) { clubPermissionPolicy.requireAdmin(1L, 99L) } + verify(exactly = 1) { clubMemberCardinalReader.findAllByClubMembers(listOf(member)) } + } + } + } + + describe("findProfileStatus") { + val club = ClubTestFixture.createClub() + val clubId = 1L + val userId = 1L + + context("프로필이 완성되고 기수가 등록된 경우") { + it("profileCompleted=true, cardinalAssigned=true, missingFields 비어있음") { + val user = + User.create( + name = "test", + email = "test@test.com", + studentId = "20200001", + tel = "01012345678", + school = "가천대학교", + department = "CS", + ) + val member = ClubMemberTestFixture.createActiveMember(club = club, user = user) + val cardinal = Cardinal.create(club = club, cardinalNumber = 7) + val memberCardinal = ClubMemberCardinal.create(member, cardinal) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { userReader.getById(userId) } returns user + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns memberCardinal + + val result = service.findProfileStatus(clubId, userId) + + result.profileCompleted shouldBe true + result.cardinalAssigned shouldBe true + result.missingFields.shouldBeEmpty() + } + } + + context("프로필이 미완성이고 기수가 미등록인 경우") { + it("profileCompleted=false, cardinalAssigned=false, missingFields에 비어있는 필드 반환") { + val user = User.create(name = "test", email = "test@test.com") + val member = ClubMemberTestFixture.createActiveMember(club = club, user = user) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { userReader.getById(userId) } returns user + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns null + + val result = service.findProfileStatus(clubId, userId) + + result.profileCompleted shouldBe false + result.cardinalAssigned shouldBe false + result.missingFields shouldContainExactlyInAnyOrder + listOf("studentId", "tel", "school", "department") + } + } + + context("프로필은 완성이나 기수가 미등록인 경우") { + it("profileCompleted=true, cardinalAssigned=false") { + val user = + User.create( + name = "test", + email = "test@test.com", + studentId = "20200001", + tel = "01012345678", + school = "가천대학교", + department = "CS", + ) + val member = ClubMemberTestFixture.createActiveMember(club = club, user = user) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { userReader.getById(userId) } returns user + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns null + + val result = service.findProfileStatus(clubId, userId) + + result.profileCompleted shouldBe true + result.cardinalAssigned shouldBe false + result.missingFields.shouldBeEmpty() + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt new file mode 100644 index 00000000..94cd84ce --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt @@ -0,0 +1,234 @@ +package com.weeth.domain.club.domain.entity + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class ClubMemberTest : + StringSpec({ + val club = + Club.create( + name = "리츠", + code = "LEETS001", + schoolName = "가천대학교", + clubContact = + ClubContact.from( + email = "leets@test.com", + phoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + val user = UserTestFixture.createActiveUser1() + + "ClubMember 생성 — 기본 상태는 WAITING, 역할은 USER, 패널티 횟수는 0" { + val member = ClubMember(club = club, user = user) + + member.memberStatus shouldBe MemberStatus.WAITING + member.memberRole shouldBe MemberRole.USER + member.penaltyCount shouldBe 0 + } + + "accept — 상태를 ACTIVE로 전환한다" { + val member = ClubMember(club = club, user = user) + + member.accept() + + member.memberStatus shouldBe MemberStatus.ACTIVE + } + + "ban — 상태를 BANNED로 전환한다" { + val member = ClubMember(club = club, user = user) + + member.ban() + + member.memberStatus shouldBe MemberStatus.BANNED + } + + "leave — ACTIVE 상태에서 LEFT로 전환한다" { + val member = ClubMember(club = club, user = user) + member.accept() + + member.leave() + + member.memberStatus shouldBe MemberStatus.LEFT + } + + "isActive — ACTIVE 상태일 때 true" { + val member = ClubMember(club = club, user = user) + member.accept() + + member.isActive() shouldBe true + } + + "isActive — WAITING 상태일 때 false" { + val member = ClubMember(club = club, user = user) + + member.isActive() shouldBe false + } + + "updateRole — 역할을 ADMIN으로 변경한다" { + val member = ClubMember(club = club, user = user) + + member.updateRole(MemberRole.ADMIN) + + member.memberRole shouldBe MemberRole.ADMIN + member.isAdminOrLead() shouldBe true + } + + "isAdmin — USER 역할일 때 false" { + val member = ClubMember(club = club, user = user) + + member.isAdminOrLead() shouldBe false + } + + "attend/absent — 출석 통계를 올바르게 계산한다" { + val member = ClubMember(club = club, user = user) + member.attend() + member.attend() + member.absent() + + member.attendanceStats.attendanceCount shouldBe 2 + member.attendanceStats.absenceCount shouldBe 1 + member.attendanceStats.attendanceRate shouldBe (2 * 100 / 3) + } + + "removeAttend — 출석 카운트를 감소시킨다" { + val member = ClubMember(club = club, user = user) + member.attend() + member.attend() + + member.removeAttend() + + member.attendanceStats.attendanceCount shouldBe 1 + } + + "removeAbsent — 결석 카운트를 감소시킨다" { + val member = ClubMember(club = club, user = user) + member.absent() + + member.removeAbsent() + + member.attendanceStats.absenceCount shouldBe 0 + } + + "resetAttendanceStats — 출석 통계를 초기화한다" { + val member = ClubMember(club = club, user = user) + member.attend() + member.attend() + member.absent() + + member.resetAttendanceStats() + + member.attendanceStats.attendanceCount shouldBe 0 + member.attendanceStats.absenceCount shouldBe 0 + member.attendanceStats.attendanceRate shouldBe 0 + } + + "incrementPenaltyCount — 패널티를 증가시킨다" { + val member = ClubMember(club = club, user = user) + + member.incrementPenaltyCount() + member.incrementPenaltyCount() + + member.penaltyCount shouldBe 2 + } + + "decrementPenaltyCount — 패널티를 감소시킨다" { + val member = ClubMember(club = club, user = user) + member.incrementPenaltyCount() + + member.decrementPenaltyCount() + + member.penaltyCount shouldBe 0 + } + + "decrementPenaltyCount — 0일 때 감소해도 0을 유지한다" { + val member = ClubMember(club = club, user = user) + + member.decrementPenaltyCount() + + member.penaltyCount shouldBe 0 + } + + "accept — WAITING이 아닌 상태에서 호출 시 예외가 발생한다" { + val member = ClubMember(club = club, user = user) + member.accept() + + shouldThrow { + member.accept() + } + } + + "ban — 이미 BANNED 상태에서 호출 시 예외가 발생한다" { + val member = ClubMember(club = club, user = user) + member.ban() + + shouldThrow { + member.ban() + } + } + + "ban — LEFT 상태에서 호출 시 예외가 발생한다" { + val member = ClubMember(club = club, user = user) + member.accept() + member.leave() + + shouldThrow { + member.ban() + } + } + + "leave — ACTIVE가 아닌 상태에서 호출 시 예외가 발생한다" { + val member = ClubMember(club = club, user = user) + + shouldThrow { + member.leave() + } + } + + "releaseLead — LEAD 멤버를 ADMIN으로 변경한다" { + val member = ClubMember(club = club, user = user, memberRole = MemberRole.LEAD) + + member.releaseLead() + + member.memberRole shouldBe MemberRole.ADMIN + } + + "releaseLead — LEAD가 아닌 멤버가 호출하면 예외가 발생한다" { + val member = ClubMember(club = club, user = user, memberRole = MemberRole.ADMIN) + + shouldThrow { + member.releaseLead() + } + } + + "assignLead — 멤버를 LEAD로 변경한다" { + val member = ClubMember(club = club, user = user) + member.accept() + + member.assignLead() + + member.memberRole shouldBe MemberRole.LEAD + } + + "updateRole — LEAD로 직접 변경 시도하면 예외가 발생한다" { + val member = ClubMember(club = club, user = user) + + shouldThrow { + member.updateRole(MemberRole.LEAD) + } + } + + "updateRole — LEAD 멤버의 역할을 직접 변경 시도하면 예외가 발생한다" { + val member = ClubMember(club = club, user = user, memberRole = MemberRole.LEAD) + + shouldThrow { + member.updateRole(MemberRole.ADMIN) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt new file mode 100644 index 00000000..c730324e --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt @@ -0,0 +1,147 @@ +package com.weeth.domain.club.domain.entity + +import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.domain.club.fixture.ClubTestFixture +import io.hypersistence.tsid.TSID +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.comparables.shouldBeGreaterThan +import io.kotest.matchers.shouldBe + +class ClubTest : + StringSpec({ + val defaultContact = + ClubContact.from( + email = "leets@test.com", + phoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ) + + "Club 생성 — 이름과 코드를 가진다" { + val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + + club.name shouldBe "리츠" + club.code shouldBe "LEETS001" + club.description shouldBe null + } + + "Club 생성 — 소개(description)를 선택적으로 가진다" { + val club = + Club.create( + name = "리츠", + code = "LEETS001", + description = "IT 동아리", + schoolName = "가천대학교", + clubContact = defaultContact, + ) + + club.description shouldBe "IT 동아리" + } + + "update — 이름과 소개를 수정한다" { + val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + + club.update( + name = "리츠2기", + schoolName = null, + description = "업데이트된 소개", + contactEmail = null, + contactPhoneNumber = null, + primaryContact = null, + profileImageStorageKey = null, + backgroundImageStorageKey = null, + ) + + club.name shouldBe "리츠2기" + club.description shouldBe "업데이트된 소개" + } + + "update — 빈 이름은 예외가 발생한다" { + val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + + shouldThrow { + club.update("", null, null, null, null, null, null, null) + } + } + + "update — 공백만 있는 이름은 예외가 발생한다" { + val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + + shouldThrow { + club.update(" ", null, null, null, null, null, null, null) + } + } + + "regenerateCode — 초대 코드를 갱신한다" { + val club = Club.create(name = "리츠", code = "OLD_CODE", schoolName = "가천대학교", clubContact = defaultContact) + + club.regenerateCode("NEW_CODE") + + club.code shouldBe "NEW_CODE" + } + + "regenerateCode — 빈 코드는 예외가 발생한다" { + val club = Club.create(name = "리츠", code = "OLD_CODE", schoolName = "가천대학교", clubContact = defaultContact) + + shouldThrow { + club.regenerateCode("") + } + } + + "create — Club id는 TSID 형식으로 생성된다" { + val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + + shouldNotThrowAny { + TSID.from(club.id) + } + } + + "create - Club id는 TSID 형식으로 시간순 정렬이 가능하다" { + val club1 = ClubTestFixture.createClub() + val club2 = ClubTestFixture.createClub() + + club2.id shouldBeGreaterThan club1.id + } + + "create — 유효한 인자로 생성에 성공한다" { + val club = + Club.create( + name = "리츠", + code = "LEETS001", + schoolName = "가천대학교", + clubContact = defaultContact, + description = "IT 동아리", + ) + + club.name shouldBe "리츠" + club.code shouldBe "LEETS001" + club.schoolName shouldBe "가천대학교" + club.description shouldBe "IT 동아리" + } + + "create — 빈 이름은 예외가 발생한다" { + shouldThrow { + Club.create(name = "", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + } + } + + "create — 공백만 있는 이름은 예외가 발생한다" { + shouldThrow { + Club.create(name = " ", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + } + } + + "create — 빈 코드는 예외가 발생한다" { + shouldThrow { + Club.create(name = "리츠", code = "", schoolName = "가천대학교", clubContact = defaultContact) + } + } + + "create — 빈 학교 이름은 예외가 발생한다" { + shouldThrow { + Club.create(name = "리츠", code = "LEETS001", schoolName = "", clubContact = defaultContact) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicyTest.kt new file mode 100644 index 00000000..2d0554ee --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicyTest.kt @@ -0,0 +1,62 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.InvalidClubCodeException +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldHaveLength +import java.util.UUID + +class ClubCodePolicyTest : + StringSpec({ + "초대 코드는 UUID로 생성되어야 한다" { + val code = ClubCodePolicy.generateCode() + code shouldHaveLength 36 + + shouldNotThrow { + UUID.fromString(code) + } + } + + "매번 생성되는 코드는 달라야 한다" { + val code1 = ClubCodePolicy.generateCode() + val code2 = ClubCodePolicy.generateCode() + code1 shouldNotBe code2 + } + + "초대 코드가 일치하면 검증 성공" { + val code = ClubCodePolicy.generateCode() + + shouldNotThrow { + ClubCodePolicy.validate(code, code) + } + } + + "초대 코드가 일치하지 않으면 예외 발생" { + val clubCode = ClubCodePolicy.generateCode() + val providedCode = ClubCodePolicy.generateCode() + + shouldThrow { + ClubCodePolicy.validate(clubCode, providedCode) + } + } + + "초대 코드는 대소문자가 달라도 검증 성공" { + val clubCode = "ABCDEF12-3456-7890-ABCD-EF1234567890" + val providedCode = "abcdef12-3456-7890-abcd-ef1234567890" + + shouldNotThrow { + ClubCodePolicy.validate(clubCode, providedCode) + } + } + + "초대 코드는 앞뒤 공백을 제거한 뒤 검증 성공" { + val code = ClubCodePolicy.generateCode().uppercase() + val providedCode = " $code " + + shouldNotThrow { + ClubCodePolicy.validate(code, providedCode) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicyTest.kt new file mode 100644 index 00000000..23c336a3 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicyTest.kt @@ -0,0 +1,107 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException +import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberReader +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk + +class ClubJoinPolicyTest : + DescribeSpec({ + val clubMemberReader = mockk() + val policy = ClubJoinPolicy(clubMemberReader) + + beforeTest { + clearMocks(clubMemberReader) + } + + describe("validateJoinLimit") { + context("USER로 가입한 동아리가 없는 경우") { + it("검증을 통과해야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + } returns 0L + + shouldNotThrowAny { + policy.validateJoinLimit(1L) + } + } + } + + context("이미 USER로 1개 동아리에 가입한 경우") { + it("ClubJoinLimitExceededException을 발생시켜야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + } returns 1L + + shouldThrow { + policy.validateJoinLimit(1L) + } + } + } + + context("LEAD로 1개 동아리를 생성했지만 USER 가입은 없는 경우") { + it("검증을 통과해야 한다 (역할이 다르므로 허용)") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + } returns 0L + + shouldNotThrowAny { + policy.validateJoinLimit(1L) + } + } + } + } + + describe("validateCreateLimit") { + context("LEAD로 생성한 동아리가 없는 경우") { + it("검증을 통과해야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.LEAD, + ) + } returns 0L + + shouldNotThrowAny { + policy.validateCreateLimit(1L) + } + } + } + + context("이미 LEAD로 1개 동아리를 생성한 경우") { + it("ClubCreateLimitExceededException을 발생시켜야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.LEAD, + ) + } returns 1L + + shouldThrow { + policy.validateCreateLimit(1L) + } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicyTest.kt new file mode 100644 index 00000000..d6239f37 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicyTest.kt @@ -0,0 +1,164 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk + +class ClubMemberCardinalPolicyTest : + DescribeSpec({ + val clubMemberCardinalReader = mockk() + val policy = ClubMemberCardinalPolicy(clubMemberCardinalReader) + + val club = ClubTestFixture.createClub() + val member = ClubMemberTestFixture.createActiveMember(club = club) + + beforeTest { + clearMocks(clubMemberCardinalReader) + } + + describe("getCurrentCardinal") { + context("기수가 존재하는 경우") { + it("최신 기수의 Cardinal을 반환해야 한다") { + val cardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 5, + ) + val memberCardinal = ClubMemberCardinal.create(clubMember = member, cardinal = cardinal) + + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns memberCardinal + + val result = policy.getCurrentCardinal(member) + + result shouldBe cardinal + } + } + + context("기수가 존재하지 않는 경우") { + it("CardinalNotFoundException을 발생시켜야 한다") { + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns null + + shouldThrow { + policy.getCurrentCardinal(member) + } + } + } + } + + describe("notContains") { + val cardinal = + CardinalTestFixture.createCardinal( + id = 10L, + club = club, + cardinalNumber = 3, + ) + + context("멤버가 해당 기수에 속하지 않는 경우") { + it("true를 반환해야 한다") { + every { + clubMemberCardinalReader.existsByClubMemberAndCardinalId(member, cardinal.id) + } returns false + + policy.notContains(member, cardinal) shouldBe true + } + } + + context("멤버가 해당 기수에 이미 속한 경우") { + it("false를 반환해야 한다") { + every { + clubMemberCardinalReader.existsByClubMemberAndCardinalId(member, cardinal.id) + } returns true + + policy.notContains(member, cardinal) shouldBe false + } + } + } + + describe("isCurrent") { + context("기수 이력이 없는 경우") { + it("true를 반환해야 한다 (첫 기수 등록)") { + val cardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 1, + ) + + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns null + + policy.isLatestOrFirstCardinal(member, cardinal) shouldBe true + } + } + + context("전달된 기수가 최신 기수보다 높은 경우") { + it("true를 반환해야 한다") { + val latestCardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 3, + ) + val newCardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 4, + ) + val latestMemberCardinal = + ClubMemberCardinal.create(clubMember = member, cardinal = latestCardinal) + + every { + clubMemberCardinalReader.findLatestCardinalByClubMember(member) + } returns latestMemberCardinal + + policy.isLatestOrFirstCardinal(member, newCardinal) shouldBe true + } + } + + context("전달된 기수가 최신 기수와 같은 경우") { + it("false를 반환해야 한다") { + val cardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 3, + ) + val memberCardinal = ClubMemberCardinal.create(clubMember = member, cardinal = cardinal) + + every { + clubMemberCardinalReader.findLatestCardinalByClubMember(member) + } returns memberCardinal + + policy.isLatestOrFirstCardinal(member, cardinal) shouldBe false + } + } + + context("전달된 기수가 최신 기수보다 낮은 경우") { + it("false를 반환해야 한다 (OB 기수 등록 시나리오)") { + val latestCardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 5, + ) + val oldCardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 2, + ) + val latestMemberCardinal = + ClubMemberCardinal.create(clubMember = member, cardinal = latestCardinal) + + every { + clubMemberCardinalReader.findLatestCardinalByClubMember(member) + } returns latestMemberCardinal + + policy.isLatestOrFirstCardinal(member, oldCardinal) shouldBe false + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt new file mode 100644 index 00000000..f014cef9 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt @@ -0,0 +1,97 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException +import com.weeth.domain.club.application.exception.ClubMemberNotInClubException +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk + +class ClubMemberPolicyTest : + DescribeSpec({ + val clubMemberReader = mockk() + val policy = ClubMemberPolicy(clubMemberReader) + + beforeTest { + clearMocks(clubMemberReader) + } + + describe("getActiveMember") { + context("활성 멤버가 존재하는 경우") { + it("활성 멤버를 반환해야 한다") { + val activeMember = + ClubTestFixture.createClubMember( + memberStatus = MemberStatus.ACTIVE, + ) + every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns activeMember + + val result = policy.getActiveMember(1L, 1L) + result.id shouldBe activeMember.id + } + } + + context("멤버가 존재하지 않는 경우") { + it("ClubMemberNotFoundException을 발생시켜야 한다") { + every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns null + + shouldThrow { + policy.getActiveMember(1L, 1L) + } + } + } + + context("멤버는 존재하지만 비활성 상태인 경우") { + it("MemberNotActiveException을 발생시켜야 한다") { + val inactiveMember = + ClubTestFixture.createClubMember( + memberStatus = MemberStatus.WAITING, + ) + every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns inactiveMember + + shouldThrow { + policy.getActiveMember(1L, 1L) + } + } + } + } + + describe("getMemberInClub") { + context("해당 동아리에 속한 멤버인 경우") { + it("멤버를 반환해야 한다") { + val member = ClubTestFixture.createClubMember() + every { clubMemberReader.findByIdOrNull(1L) } returns member + + val result = policy.getMemberInClub(member.club.id, 1L) + + result shouldBe member + } + } + + context("멤버는 존재하지만 다른 동아리에 속한 경우") { + it("ClubMemberNotInClubException을 발생시켜야 한다") { + val member = ClubTestFixture.createClubMember() + every { clubMemberReader.findByIdOrNull(1L) } returns member + + shouldThrow { + policy.getMemberInClub(member.club.id + 999L, 1L) + } + } + } + + context("멤버 자체가 존재하지 않는 경우") { + it("ClubMemberNotFoundException을 발생시켜야 한다") { + every { clubMemberReader.findByIdOrNull(2L) } returns null + + shouldThrow { + policy.getMemberInClub(1L, 2L) + } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicyTest.kt new file mode 100644 index 00000000..cc5a5359 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicyTest.kt @@ -0,0 +1,80 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.application.exception.NotClubAdminException +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk + +class ClubPermissionPolicyTest : + DescribeSpec({ + val clubMemberPolicy = mockk() + val policy = ClubPermissionPolicy(clubMemberPolicy) + + beforeTest { + clearMocks(clubMemberPolicy) + } + + describe("requireAdmin") { + context("활성 상태의 관리자인 경우") { + it("멤버를 반환해야 한다") { + val adminMember = + ClubTestFixture.createClubMember( + memberStatus = MemberStatus.ACTIVE, + memberRole = MemberRole.ADMIN, + ) + every { clubMemberPolicy.getActiveMember(1L, 1L) } returns adminMember + + val result = policy.requireAdmin(1L, 1L) + result.id shouldBe adminMember.id + } + } + + context("활성 상태의 LEAD인 경우") { + it("멤버를 반환해야 한다") { + val leadMember = + ClubTestFixture.createClubMember( + memberStatus = MemberStatus.ACTIVE, + memberRole = MemberRole.LEAD, + ) + every { clubMemberPolicy.getActiveMember(1L, 1L) } returns leadMember + + val result = policy.requireAdmin(1L, 1L) + result.id shouldBe leadMember.id + } + } + + context("활성 상태이지만 관리자가 아닌 경우") { + it("NotClubAdminException을 발생시켜야 한다") { + val userMember = + ClubTestFixture.createClubMember( + memberStatus = MemberStatus.ACTIVE, + memberRole = MemberRole.USER, + ) + every { clubMemberPolicy.getActiveMember(1L, 1L) } returns userMember + + shouldThrow { + policy.requireAdmin(1L, 1L) + } + } + } + + context("비활성 상태인 경우") { + it("MemberNotActiveException을 발생시켜야 한다") { + every { + clubMemberPolicy.getActiveMember(1L, 1L) + } throws MemberNotActiveException() + + shouldThrow { + policy.requireAdmin(1L, 1L) + } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberCardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberCardinalTestFixture.kt new file mode 100644 index 00000000..da526620 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberCardinalTestFixture.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.club.fixture + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal + +object ClubMemberCardinalTestFixture { + fun create( + clubMember: ClubMember = ClubMemberTestFixture.createActiveMember(), + cardinal: Cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 1), + ): ClubMemberCardinal = + ClubMemberCardinal( + clubMember = clubMember, + cardinal = cardinal, + ) +} diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt new file mode 100644 index 00000000..4b6dccda --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt @@ -0,0 +1,70 @@ +package com.weeth.domain.club.fixture + +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.fixture.UserTestFixture +import org.springframework.test.util.ReflectionTestUtils + +object ClubMemberTestFixture { + fun createActiveMember( + id: Long = 0L, + club: Club = ClubTestFixture.createClub(), + user: User = UserTestFixture.createActiveUser1(), + memberRole: MemberRole = MemberRole.USER, + ): ClubMember = + ClubMember( + club = club, + user = user, + memberStatus = MemberStatus.ACTIVE, + memberRole = memberRole, + ).also { if (id != 0L) ReflectionTestUtils.setField(it, "id", id) } + + fun createWaitingMember( + club: Club = ClubTestFixture.createClub(), + user: User = UserTestFixture.createWaitingUser1(), + ): ClubMember = + ClubMember( + club = club, + user = user, + memberStatus = MemberStatus.WAITING, + memberRole = MemberRole.USER, + ) + + fun createAdminMember( + club: Club = ClubTestFixture.createClub(), + user: User = UserTestFixture.createAdmin(), + ): ClubMember = + ClubMember( + club = club, + user = user, + memberStatus = MemberStatus.ACTIVE, + memberRole = MemberRole.ADMIN, + ) + + fun createBannedMember( + id: Long = 0L, + club: Club = ClubTestFixture.createClub(), + user: User = UserTestFixture.createActiveUser1(), + memberRole: MemberRole = MemberRole.USER, + ): ClubMember = + ClubMember( + club = club, + user = user, + memberStatus = MemberStatus.BANNED, + memberRole = memberRole, + ).also { if (id != 0L) ReflectionTestUtils.setField(it, "id", id) } + + fun createLeadMember( + club: Club = ClubTestFixture.createClub(), + user: User = UserTestFixture.createActiveUser1(), + ): ClubMember = + ClubMember( + club = club, + user = user, + memberStatus = MemberStatus.ACTIVE, + memberRole = MemberRole.LEAD, + ) +} diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt new file mode 100644 index 00000000..b3e96ed8 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt @@ -0,0 +1,53 @@ +package com.weeth.domain.club.fixture + +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.domain.user.fixture.UserTestFixture +import org.springframework.test.util.ReflectionTestUtils + +object ClubTestFixture { + fun createClub( + id: Long = 0L, + name: String = "테스트 동아리", + code: String = "TEST001", + description: String? = "테스트 동아리 소개", + schoolName: String = "가천대학교", + clubContact: ClubContact = + ClubContact.from( + email = "test@leets.com", + phoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ): Club { + val club = + Club.create( + name = name, + code = code, + description = description, + schoolName = schoolName, + clubContact = clubContact, + ) + if (id != 0L) ReflectionTestUtils.setField(club, "id", id) + return club + } + + fun createClubMember( + club: Club = createClub(), + user: com.weeth.domain.user.domain.entity.User = UserTestFixture.createActiveUser1(), + memberStatus: MemberStatus = MemberStatus.ACTIVE, + memberRole: MemberRole = MemberRole.USER, + ): ClubMember { + val member = + ClubMember( + club = club, + user = user, + memberStatus = memberStatus, + memberRole = memberRole, + ) + return member + } +} diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImplTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImplTest.kt deleted file mode 100644 index 337afc82..00000000 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImplTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.weeth.domain.comment.application.usecase - -import com.weeth.domain.board.domain.service.NoticeFindService -import com.weeth.domain.board.fixture.NoticeTestFixture -import com.weeth.domain.comment.application.dto.CommentDTO -import com.weeth.domain.comment.application.mapper.CommentMapper -import com.weeth.domain.comment.domain.service.CommentDeleteService -import com.weeth.domain.comment.domain.service.CommentFindService -import com.weeth.domain.comment.domain.service.CommentSaveService -import com.weeth.domain.comment.fixture.CommentTestFixture -import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.service.FileDeleteService -import com.weeth.domain.file.domain.service.FileGetService -import com.weeth.domain.file.domain.service.FileSaveService -import com.weeth.domain.user.application.exception.UserNotMatchException -import com.weeth.domain.user.domain.service.UserGetService -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldContain -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify - -class NoticeCommentUsecaseImplTest : - DescribeSpec({ - - val commentSaveService = mockk(relaxUnitFun = true) - val commentFindService = mockk() - val commentDeleteService = mockk(relaxUnitFun = true) - val fileSaveService = mockk(relaxUnitFun = true) - val fileGetService = mockk() - val fileDeleteService = mockk(relaxUnitFun = true) - val fileMapper = mockk() - val noticeFindService = mockk() - val userGetService = mockk() - val commentMapper = mockk() - - val noticeCommentUsecase = - NoticeCommentUsecaseImpl( - commentSaveService, - commentFindService, - commentDeleteService, - fileSaveService, - fileGetService, - fileDeleteService, - fileMapper, - noticeFindService, - userGetService, - commentMapper, - ) - - describe("saveNoticeComment") { - it("부모 댓글이 없는 공지사항 댓글 작성") { - val userId = 1L - val noticeId = 1L - val commentId = 1L - - val user = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = noticeId, title = "제목1") - - val dto = CommentDTO.Save(null, "댓글1", listOf()) - - val comment = CommentTestFixture.createComment(commentId, dto.content(), user, notice) - - every { commentMapper.fromCommentDto(dto, notice, user, null) } returns comment - every { userGetService.find(user.id) } returns user - every { noticeFindService.find(notice.id) } returns notice - every { fileMapper.toFileList(dto.files(), comment) } returns listOf() - - noticeCommentUsecase.saveNoticeComment(dto, noticeId, userId) - - verify { userGetService.find(userId) } - verify { noticeFindService.find(noticeId) } - verify { commentSaveService.save(comment) } - - notice.comments shouldContain comment - } - - it("부모 댓글이 있는 경우 공지사항 댓글 작성") { - val userId = 1L - val noticeId = 1L - val parentCommentId = 1L - val childCommentId = 2L - - val user = UserTestFixture.createActiveUser1(parentCommentId) - val notice = NoticeTestFixture.createNotice(id = noticeId, title = "제목1") - - val parentComment = CommentTestFixture.createComment(parentCommentId, "부모 댓글", user, notice) - - val childCommentDTO = CommentDTO.Save(parentCommentId, "자식 댓글", listOf()) - val childComment = CommentTestFixture.createComment(childCommentId, childCommentDTO.content(), user, notice) - - every { commentMapper.fromCommentDto(childCommentDTO, notice, user, parentComment) } returns childComment - every { userGetService.find(user.id) } returns user - every { commentFindService.find(parentComment.id) } returns parentComment - every { noticeFindService.find(notice.id) } returns notice - every { fileMapper.toFileList(childCommentDTO.files(), childComment) } returns listOf() - - noticeCommentUsecase.saveNoticeComment(childCommentDTO, noticeId, userId) - - verify { commentFindService.find(parentComment.id) } - verify { commentSaveService.save(childComment) } - - parentComment.children shouldContain childComment - } - } - - describe("updateNoticeComment") { - it("공지사항 댓글 수정 시 작성자와 수정 요청자가 다르면 예외가 발생한다") { - val different = 2L - val noticeId = 1L - val commentId = 1L - - val user = UserTestFixture.createActiveUser1(1L) - val user2 = UserTestFixture.createActiveUser1(2L) - val notice = NoticeTestFixture.createNotice(id = noticeId, title = "제목1") - - val dto = CommentDTO.Update("수정 완료", listOf()) - val comment = CommentTestFixture.createComment(commentId, dto.content(), user, notice) - - every { userGetService.find(user2.id) } returns user2 - every { noticeFindService.find(notice.id) } returns notice - every { commentFindService.find(comment.id) } returns comment - - shouldThrow { - noticeCommentUsecase.updateNoticeComment(dto, noticeId, comment.id, different) - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt new file mode 100644 index 00000000..e6a777d0 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -0,0 +1,366 @@ +package com.weeth.domain.comment.application.usecase.command + +import com.weeth.config.QueryCountUtil +import com.weeth.config.TestContainersConfig +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.vo.Email +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Tag +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.ThreadLocalRandom +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.roundToLong + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class, CommentConcurrencyBenchmarkConfig::class) +@Tag("performance") +class CommentConcurrencyTest( + private val postCommentUsecase: PostCommentUsecase, + private val boardRepository: BoardRepository, + private val postRepository: PostRepository, + private val userRepository: UserRepository, + private val commentRepository: CommentRepository, + private val clubRepository: ClubRepository, + private val clubMemberRepository: ClubMemberRepository, + private val entityManager: EntityManager, + private val atomicCommentCountCommand: AtomicCommentCountCommand, +) : DescribeSpec({ + + data class ConcurrencyResult( + val successCount: Int, + val failCount: Int, + val postCommentCount: Int, + val actualCommentCount: Int, + val queryCount: Long, + val elapsedTimeMs: Double, + val firstError: String?, + ) + + data class BenchmarkSummary( + val label: String, + val medianElapsedMs: Double, + val medianQueryCount: Long, + val medianThroughput: Double, + val allElapsedMs: List, + ) + + fun createUsers( + size: Int, + runId: String, + ): List = + (1..size).map { i -> + userRepository.save( + User( + name = "user$i", + email = Email.from("user-$runId-$i@test.com"), + status = Status.ACTIVE, + ), + ) + } + + fun createPost( + title: String, + user: User, + runId: String, + ): Post { + val club = + clubRepository.save( + ClubTestFixture.createClub( + name = "테스트 동아리-$runId", + code = "TEST-$runId", + ), + ) + val board = + boardRepository.save( + Board( + club = club, + name = "concurrency-board-$runId", + description = "동시성 테스트용 게시판 설명", + type = BoardType.GENERAL, + ), + ) + val clubMember = ClubMember.create(club = club, user = user).also { it.accept() } + clubMemberRepository.save(clubMember) + return postRepository.save( + Post( + title = title, + content = "내용", + clubMember = clubMember, + board = board, + ), + ) + } + + fun runConcurrentSave( + threadCount: Int, + saveAction: (postId: Long, userId: Long, index: Int) -> Unit, + ): ConcurrencyResult { + val runId = UUID.randomUUID().toString().take(8) + val users = createUsers(threadCount, runId) + val post = createPost("동시성 테스트 게시글-$runId", users.first(), runId) + // 나머지 사용자들도 같은 클럽의 ClubMember로 등록 (ACTIVE 상태로 저장) + users.drop(1).forEach { commenter -> + val member = ClubMember.create(club = post.board.club, user = commenter).also { it.accept() } + clubMemberRepository.save(member) + } + val executor = Executors.newFixedThreadPool(threadCount) + val latch = CountDownLatch(threadCount) + val successCount = AtomicInteger(0) + val failCount = AtomicInteger(0) + val firstError = AtomicReference(null) + + entityManager.clear() + + val measured = + QueryCountUtil.count(entityManager) { + repeat(threadCount) { i -> + executor.submit { + try { + saveAction(post.id, users[i].id, i) + successCount.incrementAndGet() + } catch (e: Exception) { + failCount.incrementAndGet() + firstError.compareAndSet(null, "${e::class.simpleName}: ${e.message}") + } finally { + latch.countDown() + } + } + } + + latch.await() + executor.shutdown() + } + + entityManager.clear() + val updatedPost = postRepository.findById(post.id).orElseThrow() + val actualCommentCount = + entityManager + .createQuery("select count(c) from Comment c where c.post.id = :postId", java.lang.Long::class.java) + .setParameter("postId", post.id) + .singleResult + .toInt() + + return ConcurrencyResult( + successCount = successCount.get(), + failCount = failCount.get(), + postCommentCount = updatedPost.commentCount, + actualCommentCount = actualCommentCount, + queryCount = measured.queryCount, + elapsedTimeMs = measured.elapsedTimeMs, + firstError = firstError.get(), + ) + } + + fun benchmark( + label: String, + rounds: Int, + threadCount: Int, + saveAction: (postId: Long, userId: Long, index: Int) -> Unit, + ): BenchmarkSummary { + val results = (1..rounds).map { runConcurrentSave(threadCount, saveAction) } + results.forEach { r -> + r.failCount shouldBe 0 + r.postCommentCount shouldBe threadCount + r.actualCommentCount shouldBe threadCount + } + + val elapsedSorted = results.map { it.elapsedTimeMs }.sorted() + val querySorted = results.map { it.queryCount }.sorted() + val medianElapsedMs = elapsedSorted[elapsedSorted.size / 2] + val medianQueryCount = querySorted[querySorted.size / 2] + val medianThroughput = threadCount / (medianElapsedMs / 1000.0) + + println( + "[CommentBenchmark][$label] rounds=$rounds, threadCount=$threadCount, " + + "medianElapsedMs=${medianElapsedMs.roundToLong()}, " + + "medianThroughput=${"%.2f".format(medianThroughput)} ops/s, " + + "medianQueryCount=$medianQueryCount, allElapsedMs=${elapsedSorted.map { it.roundToLong() }}", + ) + + return BenchmarkSummary( + label = label, + medianElapsedMs = medianElapsedMs, + medianQueryCount = medianQueryCount, + medianThroughput = medianThroughput, + allElapsedMs = elapsedSorted, + ) + } + + afterEach { + commentRepository.deleteAllInBatch() + postRepository.deleteAllInBatch() + boardRepository.deleteAllInBatch() + clubMemberRepository.deleteAllInBatch() + clubRepository.deleteAllInBatch() + userRepository.deleteAllInBatch() + } + + describe("동시 댓글 생성") { + it("10개의 동시 요청 후 commentCount가 정확히 10이어야 한다") { + val threadCount = 10 + val result = + runConcurrentSave(threadCount) { postId, userId, index -> + postCommentUsecase.savePostComment( + dto = CommentSaveRequest(parentCommentId = null, content = "댓글 $index", files = null), + postId = postId, + userId = userId, + ) + } + result.successCount shouldBe threadCount + result.failCount shouldBe 0 + result.postCommentCount shouldBe result.actualCommentCount + result.postCommentCount shouldBe threadCount + result.firstError shouldBe null + } + } + + describe("동시성 해소 방식별 성능 비교") { + it("PESSIMISTIC_WRITE와 Atomic Increment를 측정하고 Atomic 우위를 검증한다") { + val threadCount = 30 + val rounds = 5 + + val pessimisticSummary = + benchmark("pessimistic", rounds, threadCount) { postId, userId, index -> + postCommentUsecase.savePostComment( + dto = + CommentSaveRequest( + parentCommentId = null, + content = "pessimistic-$index", + files = null, + ), + postId = postId, + userId = userId, + ) + } + + val atomicSummary = + benchmark("atomic", rounds, threadCount) { postId, userId, index -> + atomicCommentCountCommand.savePostCommentWithAtomicIncrement( + dto = + CommentSaveRequest( + parentCommentId = null, + content = "atomic-$index", + files = null, + ), + postId = postId, + userId = userId, + ) + } + + println( + "[CommentBenchmark][compare] " + + "atomicMedian=${atomicSummary.medianElapsedMs.roundToLong()}ms, " + + "pessimisticMedian=${pessimisticSummary.medianElapsedMs.roundToLong()}ms, " + + "atomicThroughput=${"%.2f".format(atomicSummary.medianThroughput)} ops/s, " + + "pessimisticThroughput=${"%.2f".format(pessimisticSummary.medianThroughput)} ops/s", + ) + val winner = + if (atomicSummary.medianElapsedMs < + pessimisticSummary.medianElapsedMs + ) { + "atomic" + } else { + "pessimistic" + } + println("[CommentBenchmark][winner] $winner") + } + } + }) + +class AtomicCommentCountCommand( + private val commentRepository: CommentRepository, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate, +) { + fun savePostCommentWithAtomicIncrement( + dto: CommentSaveRequest, + postId: Long, + userId: Long, + ) { + val maxRetries = 20 + var lastError: Exception? = null + + repeat(maxRetries) { attempt -> + try { + transactionTemplate.executeWithoutResult { + val post = entityManager.getReference(Post::class.java, postId) + // 벤치마크 전용: commentCount 동시성 측정이 목적이므로 userId 대신 post 작성자의 ClubMember를 재사용 + val clubMember = post.clubMember + val parent = + dto.parentCommentId?.let { parentId -> + commentRepository.findByIdAndPostId(parentId, postId) + ?: throw IllegalArgumentException("parent not found") + } + + commentRepository.save( + Comment.createForPost( + content = dto.content, + post = post, + clubMember = clubMember, + parent = parent, + ), + ) + + entityManager + .createQuery("update Post p set p.commentCount = p.commentCount + 1 where p.id = :postId") + .setParameter("postId", postId) + .executeUpdate() + } + return + } catch (e: Exception) { + lastError = e + val deadlock = e.message?.contains("Deadlock found", ignoreCase = true) == true + val lockWaitTimeout = e.message?.contains("Lock wait timeout exceeded", ignoreCase = true) == true + if ((!deadlock && !lockWaitTimeout) || attempt == maxRetries - 1) { + throw e + } + val backoffMs = ThreadLocalRandom.current().nextLong(10, 40) + Thread.sleep(backoffMs) + } + } + + throw IllegalStateException("Atomic increment retries exhausted", lastError) + } +} + +@TestConfiguration +class CommentConcurrencyBenchmarkConfig { + @Bean + fun atomicCommentCountCommand( + commentRepository: CommentRepository, + entityManager: EntityManager, + transactionManager: PlatformTransactionManager, + ): AtomicCommentCountCommand = + AtomicCommentCountCommand( + commentRepository = commentRepository, + entityManager = entityManager, + transactionTemplate = TransactionTemplate(transactionManager), + ) +} diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt new file mode 100644 index 00000000..8acc5719 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt @@ -0,0 +1,220 @@ +package com.weeth.domain.comment.application.usecase.command + +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest +import com.weeth.domain.comment.application.exception.CommentAlreadyDeletedException +import com.weeth.domain.comment.application.exception.CommentNotFoundException +import com.weeth.domain.comment.application.exception.CommentNotOwnedException +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.comment.fixture.CommentTestFixture +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify + +class ManageCommentUseCaseTest : + DescribeSpec({ + val commentRepository = mockk(relaxUnitFun = true) + val postRepository = mockk() + val clubMemberPolicy = mockk(relaxed = true) + val fileReader = mockk() + val fileRepository = mockk(relaxed = true) + val fileMapper = mockk() + + val useCase = + ManageCommentUseCase( + commentRepository, + postRepository, + clubMemberPolicy, + fileReader, + fileRepository, + fileMapper, + ) + + beforeTest { + clearMocks(commentRepository, postRepository, clubMemberPolicy, fileReader, fileRepository, fileMapper) + every { fileMapper.toFileList(any(), FileOwnerType.COMMENT, any()) } returns emptyList() + every { commentRepository.save(any()) } answers { firstArg() } + every { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } returns emptyList() + every { commentRepository.delete(any()) } just runs + } + + describe("savePostComment") { + it("최상위 댓글 저장 시 댓글 수가 증가한다") { + val post = PostTestFixture.create() + val dto = CommentSaveRequest(parentCommentId = null, content = "최상위 댓글", files = null) + + every { postRepository.findByIdWithLock(10L) } returns post + + useCase.savePostComment(dto, postId = 10L, userId = 1L) + + post.commentCount shouldBe 1 + verify(exactly = 1) { commentRepository.save(any()) } + verify(exactly = 0) { commentRepository.findByIdAndPostId(any(), any()) } + } + + it("부모 댓글이 존재하지 않으면 예외를 던진다") { + val post = PostTestFixture.create() + val dto = CommentSaveRequest(parentCommentId = 999L, content = "대댓글", files = null) + + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(999L, 10L) } returns null + + shouldThrow { + useCase.savePostComment(dto, postId = 10L, userId = 1L) + } + } + } + + describe("updatePostComment") { + it("작성자가 아니면 예외를 던진다") { + val owner = UserTestFixture.createActiveUser1(1L) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) + val post = PostTestFixture.create() + val comment = CommentTestFixture.createPostComment(id = 200L, post = post, clubMember = ownerMember) + val dto = CommentUpdateRequest(content = "new", files = null) + + every { commentRepository.findByIdAndPostId(200L, 10L) } returns comment + + shouldThrow { + useCase.updatePostComment(dto, postId = 10L, commentId = 200L, userId = 2L) + } + } + + it("files가 있으면 기존 파일은 삭제되고 새 파일이 저장된다") { + val owner = UserTestFixture.createActiveUser1(1L) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) + val post = PostTestFixture.create() + val comment = CommentTestFixture.createPostComment(id = 202L, post = post, clubMember = ownerMember) + val dto = + CommentUpdateRequest( + content = "new content", + files = + listOf( + FileSaveRequest( + "new.png", + "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174001_new.png", + 200L, + "image/png", + ), + ), + ) + val oldFile = + File.createUploaded( + fileName = "old.png", + storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174002_old.png", + fileSize = 200L, + contentType = "image/png", + ownerType = FileOwnerType.COMMENT, + ownerId = comment.id, + ) + val newFile = + File.createUploaded( + fileName = "new.png", + storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174003_new.png", + fileSize = 200L, + contentType = "image/png", + ownerType = FileOwnerType.COMMENT, + ownerId = comment.id, + ) + + every { commentRepository.findByIdAndPostId(202L, 10L) } returns comment + every { fileReader.findAll(FileOwnerType.COMMENT, 202L, any()) } returns listOf(oldFile) + every { fileMapper.toFileList(dto.files, FileOwnerType.COMMENT, 202L) } returns listOf(newFile) + + useCase.updatePostComment(dto, postId = 10L, commentId = 202L, userId = 1L) + + comment.content shouldBe "new content" + verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + verify { fileRepository.saveAll(listOf(newFile)) } + } + } + + describe("deletePostComment") { + it("리프 댓글 삭제 시 hard delete 되고 댓글 수가 감소한다") { + val owner = UserTestFixture.createActiveUser1(1L) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) + val post = + PostTestFixture.create(title = "title").also { + it.increaseCommentCount() + } + val comment = CommentTestFixture.createPostComment(id = 310L, post = post, clubMember = ownerMember) + + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(310L, 10L) } returns comment + + useCase.deletePostComment(postId = 10L, commentId = 310L, userId = 1L) + + post.commentCount shouldBe 0 + verify(exactly = 1) { commentRepository.delete(comment) } + } + + it("자식이 있는 댓글 삭제 시 soft delete 된다") { + val owner = UserTestFixture.createActiveUser1(1L) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) + val post = + PostTestFixture.create().also { + it.increaseCommentCount() + it.increaseCommentCount() + } + + val comment = CommentTestFixture.createPostComment(id = 300L, post = post, clubMember = ownerMember) + val child = + CommentTestFixture.createPostComment( + id = 301L, + post = post, + clubMember = ownerMember, + parent = comment, + ) + comment.children.add(child) + + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(300L, 10L) } returns comment + + useCase.deletePostComment(postId = 10L, commentId = 300L, userId = 1L) + + comment.isDeleted shouldBe true + comment.content shouldBe "삭제된 댓글입니다." + post.commentCount shouldBe 1 + verify(exactly = 0) { commentRepository.delete(comment) } + } + + it("이미 삭제된 댓글은 삭제할 수 없다") { + val owner = UserTestFixture.createActiveUser1(1L) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) + val post = PostTestFixture.create() + val comment = + CommentTestFixture.createPostComment( + id = 320L, + post = post, + clubMember = ownerMember, + isDeleted = true, + ) + + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(320L, 10L) } returns comment + + shouldThrow { + useCase.deletePostComment(postId = 10L, commentId = 320L, userId = 1L) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt new file mode 100644 index 00000000..6ef57039 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt @@ -0,0 +1,240 @@ +package com.weeth.domain.comment.application.usecase.query + +import com.weeth.config.QueryCountUtil +import com.weeth.config.TestContainersConfig +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.comment.application.mapper.CommentMapper +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.vo.Email +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.longs.shouldBeLessThan +import io.kotest.matchers.shouldBe +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Tag +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.util.UUID + +@DataJpaTest +@Import(TestContainersConfig::class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Tag("performance") +class CommentQueryPerformanceTest( + private val userRepository: UserRepository, + private val boardRepository: BoardRepository, + private val postRepository: PostRepository, + private val commentRepository: CommentRepository, + private val fileRepository: FileRepository, + private val clubRepository: ClubRepository, + private val clubMemberRepository: ClubMemberRepository, + private val entityManager: EntityManager, +) : DescribeSpec({ + val runPerformanceTests = System.getProperty("runPerformanceTests")?.toBoolean() ?: false + + fun createUser(): User = + userRepository.save( + User( + name = "perf-user", + email = Email.from("perf-user@test.com"), + department = "컴퓨터공학과", + status = Status.ACTIVE, + ), + ) + + fun createBoard(): Board { + val club = clubRepository.save(ClubTestFixture.createClub()) + return boardRepository.save( + Board( + club = club, + name = "perf-board", + description = "성능 테스트용 게시판 설명", + type = BoardType.GENERAL, + ), + ) + } + + fun createPost( + clubMember: ClubMember, + board: Board, + ): Post = + postRepository.save( + Post( + title = "query-performance", + content = "measure comment query performance", + clubMember = clubMember, + board = board, + cardinalNumber = 4, + ), + ) + + data class SetupResult( + val commentIds: List, + ) + + fun setupData( + rootCount: Int, + childrenPerRoot: Int, + filesPerComment: Int, + ): SetupResult { + val user = createUser() + val board = createBoard() + val clubMember = ClubMember.create(club = board.club, user = user).also { it.accept() } + clubMemberRepository.save(clubMember) + val post = createPost(clubMember, board) + + val commentIds = mutableListOf() + repeat(rootCount) { rootIdx -> + val root = + commentRepository.save( + Comment.createForPost( + content = "root-$rootIdx", + post = post, + clubMember = clubMember, + parent = null, + ), + ) + commentIds += root.id + repeat(childrenPerRoot) { childIdx -> + val child = + commentRepository.save( + Comment.createForPost( + content = "child-$rootIdx-$childIdx", + post = post, + clubMember = clubMember, + parent = root, + ), + ) + commentIds += child.id + } + } + + commentIds.forEach { commentId -> + repeat(filesPerComment) { fileIdx -> + fileRepository.save( + File.createUploaded( + fileName = "file-$commentId-$fileIdx.png", + storageKey = "COMMENT/2026-02/${UUID.randomUUID()}_file-$commentId-$fileIdx.png", + fileSize = 1024L, + contentType = "image/png", + ownerType = FileOwnerType.COMMENT, + ownerId = commentId, + ), + ) + } + } + + return SetupResult(commentIds) + } + + describe("comment file query performance") { + fun runComparison( + label: String, + rootCount: Int, + childrenPerRoot: Int, + filesPerComment: Int, + ) { + setupData( + rootCount = rootCount, + childrenPerRoot = childrenPerRoot, + filesPerComment = filesPerComment, + ) + + val fileAccessUrlPort = + object : FileAccessUrlPort { + override fun resolve(storageKey: String): String = "https://test.local/$storageKey" + } + val fileMapper = FileMapper(fileAccessUrlPort) + val commentMapper = CommentMapper(fileAccessUrlPort) + val legacyService = LegacyCommentQueryService(fileRepository, fileMapper, commentMapper) + val improvedService = GetCommentQueryService(fileRepository, fileMapper, commentMapper) + + entityManager.flush() + entityManager.clear() + + val legacy = + QueryCountUtil.count(entityManager) { + val comments = commentRepository.findAll().sortedBy { it.id } + val tree = legacyService.toCommentTreeResponses(comments) + tree.size shouldBe rootCount + } + + entityManager.clear() + + val improved = + QueryCountUtil.count(entityManager) { + val comments = commentRepository.findAll().sortedBy { it.id } + val tree = improvedService.toCommentTreeResponses(comments) + tree.size shouldBe rootCount + } + + improved.queryCount shouldBeLessThan legacy.queryCount + println("[$label] LEGACY: $legacy") + println("[$label] IMPROVED: $improved") + } + + it("소규모 데이터에서 배치 조회가 더 효율적이다").config(enabled = runPerformanceTests) { + runComparison(label = "small", rootCount = 10, childrenPerRoot = 1, filesPerComment = 1) + } + + it("대량 데이터에서도 배치 조회가 더 효율적이다").config(enabled = runPerformanceTests) { + runComparison(label = "large", rootCount = 200, childrenPerRoot = 1, filesPerComment = 1) + } + } + }) + +private class LegacyCommentQueryService( + private val fileRepository: FileRepository, + private val fileMapper: FileMapper, + private val commentMapper: CommentMapper, +) { + fun toCommentTreeResponses(comments: List): List { + if (comments.isEmpty()) { + return emptyList() + } + + val childrenByParentId = + comments + .filter { it.parent != null } + .groupBy { requireNotNull(it.parent).id } + + return comments + .filter { it.parent == null } + .map { mapToCommentResponse(it, childrenByParentId) } + } + + private fun mapToCommentResponse( + comment: Comment, + childrenByParentId: Map>, + ): CommentResponse { + val children = + childrenByParentId[comment.id] + ?.map { mapToCommentResponse(it, childrenByParentId) } + ?: emptyList() + + val files = + fileRepository + .findAll(FileOwnerType.COMMENT, comment.id) + .map(fileMapper::toFileResponse) + + return commentMapper.toCommentDto(comment, children, files) + } +} diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt new file mode 100644 index 00000000..c2abfcc7 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -0,0 +1,96 @@ +package com.weeth.domain.comment.application.usecase.query + +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.comment.application.mapper.CommentMapper +import com.weeth.domain.comment.fixture.CommentTestFixture +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.user.application.dto.response.UserInfo +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDateTime + +class GetCommentQueryServiceTest : + DescribeSpec({ + val fileReader = mockk() + val fileMapper = mockk() + val commentMapper = mockk() + val service = GetCommentQueryService(fileReader, fileMapper, commentMapper) + + val user = UserTestFixture.createActiveUser1(1L) + val member = ClubMemberTestFixture.createActiveMember(user = user) + val post = PostTestFixture.create(clubMember = member) + + beforeTest { + clearMocks(fileReader, fileMapper, commentMapper) + } + + fun stubResponse( + id: Long, + children: List = emptyList(), + ) = CommentResponse( + id = id, + author = UserInfo(id = 1L, name = "테스트유저", profileImageUrl = null, role = MemberRole.USER), + content = "content", + time = LocalDateTime.now(), + fileUrls = emptyList(), + children = children, + ) + + describe("toCommentTreeResponses") { + it("빈 리스트면 빈 리스트를 반환하고 파일 조회를 하지 않는다") { + val result = service.toCommentTreeResponses(emptyList()) + + result shouldBe emptyList() + verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } + verify(exactly = 0) { fileReader.findAll(any(), any>(), any()) } + } + + it("최상위 댓글만 있을 때 파일 조회를 1회 수행한다") { + val comment = CommentTestFixture.createPostComment(id = 1L, post = post, clubMember = member) + val response = stubResponse(1L) + + every { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } returns emptyList() + every { commentMapper.toCommentDto(comment, emptyList(), emptyList()) } returns response + + val result = service.toCommentTreeResponses(listOf(comment)) + + result.size shouldBe 1 + result[0].id shouldBe 1L + verify(exactly = 1) { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } + } + + it("부모-자식 구조를 트리로 조립한다") { + val parent = CommentTestFixture.createPostComment(id = 10L, post = post, clubMember = member) + val child = + CommentTestFixture.createPostComment( + id = 11L, + post = post, + clubMember = member, + parent = parent, + ) + val childResponse = stubResponse(11L) + val parentResponse = stubResponse(10L, children = listOf(childResponse)) + + every { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } returns emptyList() + every { commentMapper.toCommentDto(child, emptyList(), emptyList()) } returns childResponse + every { commentMapper.toCommentDto(parent, listOf(childResponse), emptyList()) } returns parentResponse + + val result = service.toCommentTreeResponses(listOf(parent, child)) + + result.size shouldBe 1 + result[0].id shouldBe 10L + result[0].children.size shouldBe 1 + result[0].children[0].id shouldBe 11L + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt new file mode 100644 index 00000000..1223fa8c --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt @@ -0,0 +1,35 @@ +package com.weeth.domain.comment.domain.entity + +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.comment.fixture.CommentTestFixture +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class CommentEntityTest : + DescribeSpec({ + val member = ClubMemberTestFixture.createActiveMember() + val post = PostTestFixture.create(title = "title", clubMember = member) + + describe("createForPost") { + it("부모 없이 최상위 댓글을 생성한다") { + val comment = Comment.createForPost(content = "내용", post = post, clubMember = member, parent = null) + + comment.content shouldBe "내용" + comment.post shouldBe post + comment.clubMember shouldBe member + comment.parent shouldBe null + } + } + + describe("markAsDeleted") { + it("isDeleted를 true로 바꾸고 내용을 대체 문구로 변경한다") { + val comment = CommentTestFixture.createPostComment(post = post, clubMember = member) + + comment.markAsDeleted() + + comment.isDeleted shouldBe true + comment.content shouldBe "삭제된 댓글입니다." + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/comment/domain/vo/CommentContentTest.kt b/src/test/kotlin/com/weeth/domain/comment/domain/vo/CommentContentTest.kt new file mode 100644 index 00000000..0328501c --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/domain/vo/CommentContentTest.kt @@ -0,0 +1,38 @@ +package com.weeth.domain.comment.domain.vo + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class CommentContentTest : + StringSpec({ + "정상 내용이면 생성된다" { + val content = CommentContent.from("정상 내용") + + content.value shouldBe "정상 내용" + } + + "빈 문자열이면 예외를 던진다" { + shouldThrow { + CommentContent.from("") + } + } + + "공백만 있으면 예외를 던진다" { + shouldThrow { + CommentContent.from(" ") + } + } + + "300자는 허용된다" { + val content = CommentContent.from("a".repeat(300)) + + content.value.length shouldBe 300 + } + + "301자이면 예외를 던진다" { + shouldThrow { + CommentContent.from("a".repeat(301)) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt b/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt index 24da6e23..0b7b1d81 100644 --- a/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt @@ -1,23 +1,24 @@ package com.weeth.domain.comment.fixture -import com.weeth.domain.board.domain.entity.Notice +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.comment.domain.entity.Comment -import com.weeth.domain.user.domain.entity.User object CommentTestFixture { - fun createComment( - id: Long, - content: String, - user: User, - notice: Notice, - ): Comment = - Comment - .builder() - .id(id) - .content(content) - .notice(notice) - .user(user) - .children(ArrayList()) - .isDeleted(false) - .build() + fun createPostComment( + id: Long = 1L, + content: String = "테스트 댓글", + post: Post, + clubMember: ClubMember = ClubMemberTestFixture.createActiveMember(), + parent: Comment? = null, + isDeleted: Boolean = false, + ) = Comment( + id = id, + content = content, + post = post, + clubMember = clubMember, + parent = parent, + isDeleted = isDeleted, + ) } diff --git a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt new file mode 100644 index 00000000..1121be26 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt @@ -0,0 +1,348 @@ +package com.weeth.domain.dashboard.application.usecase.query + +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardReader +import com.weeth.domain.board.domain.repository.PostLikeReader +import com.weeth.domain.board.domain.repository.PostReader +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.dashboard.application.mapper.DashboardMapper +import com.weeth.domain.dashboard.domain.enums.ScheduleType +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.schedule.domain.repository.EventReader +import com.weeth.domain.schedule.fixture.ScheduleTestFixture +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.SliceImpl +import java.time.LocalDateTime + +class GetDashboardQueryServiceTest : + DescribeSpec({ + val boardReader = mockk() + val postLikeReader = mockk() + val clubReader = mockk() + val clubMemberReader = mockk() + val clubMemberPolicy = ClubMemberPolicy(clubMemberReader) + val eventReader = mockk() + val sessionReader = mockk() + val postReader = mockk() + val fileReader = mockk() + val userReader = mockk() + val fileMapper = mockk() + val fileAccessUrlPort = mockk() + val dashboardMapper = DashboardMapper(fileMapper, fileAccessUrlPort) + + val queryService = + GetDashboardQueryService( + boardReader = boardReader, + postLikeReader = postLikeReader, + clubReader = clubReader, + clubMemberReader = clubMemberReader, + clubMemberPolicy = clubMemberPolicy, + eventReader = eventReader, + sessionReader = sessionReader, + postReader = postReader, + fileReader = fileReader, + userReader = userReader, + dashboardMapper = dashboardMapper, + ) + + val clubId = 1L + val userId = 1L + val club = ClubTestFixture.createClub() + val clubMember = ClubTestFixture.createClubMember(club = club) + val user = UserTestFixture.createActiveUser1(1L) + + beforeTest { + clearMocks( + boardReader, + postLikeReader, + clubReader, + clubMemberReader, + eventReader, + sessionReader, + postReader, + fileReader, + userReader, + fileMapper, + ) + } + + describe("getHome") { + context("활성 멤버인 경우") { + it("홈 정보를 반환한다") { + every { clubReader.getClubById(clubId) } returns club + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { clubMemberReader.countActiveByClubId(clubId) } returns 10L + every { eventReader.findByClubIdAndDateRange(clubId, any(), any()) } returns emptyList() + every { + sessionReader.findAllByClubIdAndStartBetween(clubId, any(), any()) + } returns emptyList() + every { clubMemberReader.findActiveByUserId(userId) } returns listOf(clubMember) + every { userReader.getById(userId) } returns user + + val result = queryService.getHome(clubId, userId) + + result shouldNotBe null + result.club.memberCount shouldBe 10L + result.myClubs.size shouldBe 1 + } + } + + context("멤버가 아닌 경우") { + it("ClubMemberNotFoundException을 던진다") { + every { clubReader.getClubById(clubId) } returns club + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns null + + shouldThrow { + queryService.getHome(clubId, userId) + } + } + } + + context("비활성 멤버인 경우") { + it("MemberNotActiveException을 던진다") { + val inactiveMember = + ClubTestFixture.createClubMember( + club = club, + memberStatus = MemberStatus.BANNED, + ) + every { clubReader.getClubById(clubId) } returns club + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns inactiveMember + + shouldThrow { + queryService.getHome(clubId, userId) + } + } + } + + context("오늘 일정이 있는 경우") { + it("이벤트와 세션을 시작 시간순으로 정렬하여 반환한다") { + val event = + ScheduleTestFixture.createEvent( + id = 1L, + start = LocalDateTime.now().withHour(10).withMinute(0), + end = LocalDateTime.now().withHour(12).withMinute(0), + ) + val session = + SessionTestFixture.createSession( + id = 2L, + start = LocalDateTime.now().withHour(14).withMinute(0), + end = LocalDateTime.now().withHour(16).withMinute(0), + ) + + every { clubReader.getClubById(clubId) } returns club + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { clubMemberReader.countActiveByClubId(clubId) } returns 5L + every { eventReader.findByClubIdAndDateRange(clubId, any(), any()) } returns listOf(event) + every { + sessionReader.findAllByClubIdAndStartBetween(clubId, any(), any()) + } returns listOf(session) + every { clubMemberReader.findActiveByUserId(userId) } returns listOf(clubMember) + every { userReader.getById(userId) } returns user + + val result = queryService.getHome(clubId, userId) + + result.todaySchedules.size shouldBe 2 + result.todaySchedules[0].type shouldBe ScheduleType.EVENT + result.todaySchedules[1].type shouldBe ScheduleType.SESSION + } + } + } + + describe("getRecentPosts") { + val memberWithUser = ClubTestFixture.createClubMember(club = club, user = user) + + context("멤버인 경우") { + it("공지 제외한 접근 가능한 게시판의 최신 게시글을 반환한다") { + val board = BoardTestFixture.create(id = 10L, type = BoardType.GENERAL) + val post = PostTestFixture.create(board = board, clubMember = memberWithUser) + val pageable = PageRequest.of(0, 10) + val slice = SliceImpl(listOf(post), pageable, false) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns memberWithUser + every { boardReader.findAllActiveByClubId(clubId) } returns listOf(board) + every { postReader.findRecentByBoardIds(listOf(board.id), any()) } returns slice + every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() + every { postLikeReader.findLikedPostIds(listOf(post.id), userId) } returns emptySet() + + val result = queryService.getRecentPosts(clubId, userId, 0, 10) + + result.content.size shouldBe 1 + result.content[0].boardId shouldBe board.id + result.content[0].fileUrls.isEmpty() shouldBe true + result.content[0].like.isLiked shouldBe false + } + } + + context("비공개 게시판이 있는 경우") { + val privateBoard = + BoardTestFixture.create( + id = 11L, + type = BoardType.GENERAL, + config = BoardConfig(isPrivate = true), + ) + + it("일반 멤버에게는 비공개 게시판 글이 포함되지 않는다") { + val publicBoard = BoardTestFixture.create(id = 10L, type = BoardType.GENERAL) + val post = PostTestFixture.create(board = publicBoard, clubMember = memberWithUser) + val pageable = PageRequest.of(0, 10) + val slice = SliceImpl(listOf(post), pageable, false) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns memberWithUser + every { boardReader.findAllActiveByClubId(clubId) } returns listOf(publicBoard, privateBoard) + every { postReader.findRecentByBoardIds(listOf(publicBoard.id), any()) } returns slice + every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() + every { postLikeReader.findLikedPostIds(listOf(post.id), userId) } returns emptySet() + + val result = queryService.getRecentPosts(clubId, userId, 0, 10) + + result.content.size shouldBe 1 + } + + it("ADMIN 멤버에게는 비공개 게시판 글이 포함된다") { + val adminMember = + ClubTestFixture.createClubMember( + club = club, + user = user, + memberRole = MemberRole.ADMIN, + ) + val post = PostTestFixture.create(board = privateBoard, clubMember = adminMember) + val pageable = PageRequest.of(0, 10) + val slice = SliceImpl(listOf(post), pageable, false) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns adminMember + every { boardReader.findAllActiveByClubId(clubId) } returns listOf(privateBoard) + every { postReader.findRecentByBoardIds(listOf(privateBoard.id), any()) } returns slice + every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() + every { postLikeReader.findLikedPostIds(listOf(post.id), userId) } returns emptySet() + + val result = queryService.getRecentPosts(clubId, userId, 0, 10) + + result.content.size shouldBe 1 + } + } + + context("접근 가능한 게시판이 없는 경우") { + it("빈 Slice를 반환한다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns memberWithUser + every { boardReader.findAllActiveByClubId(clubId) } returns emptyList() + + val result = queryService.getRecentPosts(clubId, userId, 0, 10) + + result.content.isEmpty() shouldBe true + result.hasNext() shouldBe false + } + } + + context("멤버가 아닌 경우") { + it("ClubMemberNotFoundException을 던진다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns null + + shouldThrow { + queryService.getRecentPosts(clubId, userId, 0, 10) + } + } + } + } + + describe("getRecentNotices") { + context("멤버인 경우") { + it("최신 공지 목록을 반환한다") { + val noticeBoard = BoardTestFixture.create(type = BoardType.NOTICE) + val notice = PostTestFixture.create(board = noticeBoard) + val pageable = PageRequest.of(0, 5) + val slice = SliceImpl(listOf(notice), pageable, false) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { postReader.findRecentByClubIdAndBoardType(clubId, BoardType.NOTICE, any()) } returns slice + + val result = queryService.getRecentNotices(clubId, userId, 5) + + result.size shouldBe 1 + } + } + } + + describe("getMonthlySchedules") { + context("멤버인 경우") { + it("월간 일정 목록을 시작 시간순으로 반환한다") { + val event = ScheduleTestFixture.createEvent(id = 1L) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { eventReader.findByClubIdAndDateRange(clubId, any(), any()) } returns listOf(event) + every { + sessionReader.findAllByClubIdAndStartBetween(clubId, any(), any()) + } returns emptyList() + + val result = queryService.getMonthlySchedules(clubId, userId) + + result.size shouldBe 1 + result[0].type shouldBe ScheduleType.EVENT + } + } + } + + describe("getUnreadNotice") { + context("읽지 않은 공지가 있는 경우") { + it("읽지 않은 최신 공지 1건을 반환한다") { + val noticeBoard = BoardTestFixture.create(type = BoardType.NOTICE) + val notice = PostTestFixture.create(board = noticeBoard) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { postReader.findFirstUnreadNoticeSince(clubId, userId, BoardType.NOTICE, any()) } returns + notice + + val result = queryService.getUnreadNotice(clubId, userId) + + result shouldNotBe null + } + } + + context("모든 공지를 읽은 경우") { + it("null을 반환한다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { postReader.findFirstUnreadNoticeSince(clubId, userId, BoardType.NOTICE, any()) } returns + null + + val result = queryService.getUnreadNotice(clubId, userId) + + result shouldBe null + } + } + + context("2주 내 공지가 없는 경우") { + it("null을 반환한다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { postReader.findFirstUnreadNoticeSince(clubId, userId, BoardType.NOTICE, any()) } returns + null + + val result = queryService.getUnreadNotice(clubId, userId) + + result shouldBe null + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/application/mapper/FileMapperTest.kt b/src/test/kotlin/com/weeth/domain/file/application/mapper/FileMapperTest.kt new file mode 100644 index 00000000..9e070961 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/application/mapper/FileMapperTest.kt @@ -0,0 +1,57 @@ +package com.weeth.domain.file.application.mapper + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.mockk + +class FileMapperTest : + DescribeSpec({ + val fileAccessUrlPort = mockk(relaxed = true) + val fileMapper = FileMapper(fileAccessUrlPort) + + describe("toFileList") { + it("요청이 null이면 빈 리스트를 반환한다") { + val result = fileMapper.toFileList(null, FileOwnerType.POST, 1L) + + result shouldBe emptyList() + } + + it("요청이 비어있으면 빈 리스트를 반환한다") { + val result = fileMapper.toFileList(emptyList(), FileOwnerType.POST, 1L) + + result shouldBe emptyList() + } + + it("요청 리스트를 ownerType/ownerId를 포함한 File 리스트로 매핑한다") { + val requests = + listOf( + FileSaveRequest( + "a.png", + "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", + 100L, + "image/png", + ), + FileSaveRequest( + "b.pdf", + "POST/2026-02/550e8400-e29b-41d4-a716-446655440001_b.pdf", + 200L, + "application/pdf", + ), + ) + + val result = fileMapper.toFileList(requests, FileOwnerType.POST, 99L) + + result shouldHaveSize 2 + result[0].fileName shouldBe "a.png" + result[0].ownerType shouldBe FileOwnerType.POST + result[0].ownerId shouldBe 99L + result[1].fileName shouldBe "b.pdf" + result[1].ownerType shouldBe FileOwnerType.POST + result[1].ownerId shouldBe 99L + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecaseTest.kt b/src/test/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecaseTest.kt new file mode 100644 index 00000000..8ec22d58 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecaseTest.kt @@ -0,0 +1,65 @@ +package com.weeth.domain.file.application.usecase.command + +import com.weeth.domain.file.application.dto.response.UrlResponse +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.port.FileUploadUrl +import com.weeth.domain.file.domain.port.FileUploadUrlPort +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GenerateFileUrlUsecaseTest : + DescribeSpec({ + val preSignedService = mockk() + val fileMapper = mockk() + val useCase = GenerateFileUrlUsecase(preSignedService, fileMapper) + + beforeEach { + clearMocks(preSignedService, fileMapper) + } + + describe("getUrl") { + it("요청한 파일명 순서대로 presigned URL을 반환한다") { + val fileNames = listOf("a.png", "b.pdf") + val ownerType = FileOwnerType.POST + val responses = + listOf( + UrlResponse("a.png", "https://presigned/a", "POST/2026-02/a.png"), + UrlResponse("b.pdf", "https://presigned/b", "POST/2026-02/b.pdf"), + ) + val firstPresigned = FileUploadUrl("a.png", "POST/2026-02/a.png", "https://presigned/a") + val secondPresigned = FileUploadUrl("b.pdf", "POST/2026-02/b.pdf", "https://presigned/b") + + every { preSignedService.generateUploadUrl(ownerType, "a.png") } returns firstPresigned + every { preSignedService.generateUploadUrl(ownerType, "b.pdf") } returns secondPresigned + every { fileMapper.toUrlResponse("a.png", "https://presigned/a", "POST/2026-02/a.png") } returns + responses[0] + every { fileMapper.toUrlResponse("b.pdf", "https://presigned/b", "POST/2026-02/b.pdf") } returns + responses[1] + + val result = useCase.generateFileUploadUrls(ownerType, fileNames) + + result shouldContainExactly responses + verify(exactly = 1) { preSignedService.generateUploadUrl(ownerType, "a.png") } + verify(exactly = 1) { preSignedService.generateUploadUrl(ownerType, "b.pdf") } + } + + it("Presigned URL 생성 포트에서 예외가 나면 그대로 전파한다") { + val ownerType = FileOwnerType.POST + val fileNames = listOf("a.png") + + every { preSignedService.generateUploadUrl(ownerType, "a.png") } throws RuntimeException("s3 error") + + shouldThrow { + useCase.generateFileUploadUrls(ownerType, fileNames) + } + + verify(exactly = 0) { fileMapper.toUrlResponse(any(), any(), any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt new file mode 100644 index 00000000..cdcb071c --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt @@ -0,0 +1,120 @@ +package com.weeth.domain.file.domain.entity + +import com.weeth.domain.file.application.exception.UnsupportedFileContentTypeException +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class FileTest : + DescribeSpec({ + describe("createUploaded") { + it("유효한 입력이면 업로드 상태 파일을 생성한다") { + val file = + File.createUploaded( + fileName = "image.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + + file.fileName shouldBe "image.png" + file.ownerType shouldBe FileOwnerType.POST + file.status shouldBe FileStatus.UPLOADED + } + + it("fileName이 blank면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = " ", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + + it("storageKey가 blank면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "image.png", + storageKey = " ", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + + it("storageKey ownerType 형식이 잘못되면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "image.png", + storageKey = "INVALID/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + + it("storageKey uuid 형식이 잘못되면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "image.png", + storageKey = "POST/2026-02/not-uuid_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + + it("fileSize가 0 이하이면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "image.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 0, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + + it("ownerId가 0 이하이면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "image.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 0, + ) + } + } + + it("허용되지 않은 contentType이면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "file.exe", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_file.exe", + fileSize = 100, + contentType = "application/octet-stream", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt new file mode 100644 index 00000000..d4096a6e --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt @@ -0,0 +1,115 @@ +package com.weeth.domain.file.domain.repository + +import com.weeth.config.TestContainersConfig +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.util.ReflectionTestUtils +import java.util.UUID + +@DataJpaTest +@Import(TestContainersConfig::class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class FileRepositoryTest( + private val fileRepository: FileRepository, + private val jdbcTemplate: JdbcTemplate, +) : DescribeSpec({ + describe("save") { + it("파일 정보를 저장하고 조회한다") { + val saved = + fileRepository.save( + createTestFile( + fileName = "notice-image.png", + ownerType = FileOwnerType.POST, + ownerId = 101L, + status = FileStatus.UPLOADED, + ), + ) + + val found = fileRepository.findById(saved.id).orElseThrow() + + found.fileName shouldBe "notice-image.png" + found.ownerType shouldBe FileOwnerType.POST + found.ownerId shouldBe 101L + found.status shouldBe FileStatus.UPLOADED + } + } + + describe("findAll/exists") { + it("ownerType + ownerId + status 조건에 맞는 데이터만 조회한다") { + fileRepository.save(createTestFile("target-1.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("target-2.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("deleted.png", FileOwnerType.POST, 77L, FileStatus.DELETED)) + fileRepository.save(createTestFile("other-owner.png", FileOwnerType.POST, 78L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("other-type.png", FileOwnerType.RECEIPT, 77L, FileStatus.UPLOADED)) + + val uploaded = fileRepository.findAll(FileOwnerType.POST, 77L, FileStatus.UPLOADED) + val allStatus = fileRepository.findAll(FileOwnerType.POST, 77L, null) + + uploaded.map { it.fileName }.sorted() shouldContainExactly listOf("target-1.png", "target-2.png") + allStatus.map { it.fileName }.sorted() shouldContainExactly + listOf("deleted.png", "target-1.png", "target-2.png") + + fileRepository.exists(FileOwnerType.POST, 77L, FileStatus.UPLOADED).shouldBeTrue() + fileRepository.exists(FileOwnerType.POST, 77L, FileStatus.DELETED).shouldBeTrue() + fileRepository.exists(FileOwnerType.POST, 99L, FileStatus.UPLOADED).shouldBeFalse() + } + } + + describe("index usage") { + it("owner_type + owner_id 조건 조회 시 복합 인덱스를 사용한다") { + fileRepository.save(createTestFile("index-target.png", FileOwnerType.RECEIPT, 55L, FileStatus.UPLOADED)) + + val explain = + jdbcTemplate + .queryForList( + "EXPLAIN SELECT id FROM `file` WHERE owner_type = ? AND owner_id = ?", + FileOwnerType.RECEIPT.name, + 55L, + ).first() + + val possibleKeys = explain.valueBy("possible_keys") + val selectedKey = explain.valueBy("key") + + possibleKeys shouldContain "idx_file_owner_type_owner_id" + selectedKey shouldBe "idx_file_owner_type_owner_id" + } + } + }) + +private fun createTestFile( + fileName: String, + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus, +): File = + File + .createUploaded( + fileName = fileName, + storageKey = "${ownerType.name}/2026-02/${UUID.randomUUID()}_$fileName", + fileSize = 1024L, + contentType = "image/png", + ownerType = ownerType, + ownerId = ownerId, + ).also { + if (status == FileStatus.DELETED) { + ReflectionTestUtils.setField(it, "status", FileStatus.DELETED) + } + } + +private fun Map.valueBy(key: String): String = + entries + .first { + it.key.equals(key, ignoreCase = true) + }.value + .toString() diff --git a/src/test/kotlin/com/weeth/domain/file/domain/vo/FileNameTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/vo/FileNameTest.kt new file mode 100644 index 00000000..0052c501 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/domain/vo/FileNameTest.kt @@ -0,0 +1,25 @@ +package com.weeth.domain.file.domain.vo + +import com.weeth.domain.file.application.exception.UnsupportedFileExtensionException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class FileNameTest : + DescribeSpec({ + describe("FileName") { + it("파일명 sanitize와 확장자 검증을 수행한다") { + val fileName = FileName(" report:2026.PDF ") + + fileName.sanitized shouldBe "report_2026.PDF" + fileName.extension.normalized shouldBe "pdf" + fileName.extension.fileType shouldBe FileType.PDF + } + + it("허용되지 않은 확장자는 예외를 던진다") { + shouldThrow { + FileName("payload.exe") + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/domain/vo/FileTypeTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/vo/FileTypeTest.kt new file mode 100644 index 00000000..8c376512 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/domain/vo/FileTypeTest.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.file.domain.vo + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class FileTypeTest : + DescribeSpec({ + describe("FileType") { + it("contentType으로 타입을 조회한다") { + FileType.fromContentType("image/png") shouldBe FileType.PNG + FileType.fromContentType("application/pdf") shouldBe FileType.PDF + } + + it("extension으로 타입을 조회한다") { + FileType.fromExtension("jpg") shouldBe FileType.JPEG + FileType.fromExtension("jpeg") shouldBe FileType.JPEG + FileType.fromExtension("webp") shouldBe FileType.WEBP + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt b/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt index a42c852c..9903f4af 100644 --- a/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt @@ -1,20 +1,27 @@ package com.weeth.domain.file.fixture -import com.weeth.domain.board.domain.entity.Notice import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.vo.FileContentType +import com.weeth.domain.file.domain.vo.StorageKey object FileTestFixture { fun createFile( id: Long, fileName: String, - fileUrl: String, - notice: Notice? = null, + storageKey: StorageKey = StorageKey("POST/2026-02/00000000-0000-0000-0000-000000000000_test.png"), + fileSize: Long = 1024, + ownerType: FileOwnerType = FileOwnerType.POST, + ownerId: Long = 1L, + contentType: FileContentType = FileContentType("image/png"), ): File = - File - .builder() - .id(id) - .fileName(fileName) - .fileUrl(fileUrl) - .notice(notice) - .build() + File( + id = id, + fileName = fileName, + storageKey = storageKey, + fileSize = fileSize, + ownerType = ownerType, + ownerId = ownerId, + contentType = contentType, + ) } diff --git a/src/test/kotlin/com/weeth/domain/file/infrastructure/FileAccessUrlAdapterTest.kt b/src/test/kotlin/com/weeth/domain/file/infrastructure/FileAccessUrlAdapterTest.kt new file mode 100644 index 00000000..31e4520c --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/infrastructure/FileAccessUrlAdapterTest.kt @@ -0,0 +1,42 @@ +package com.weeth.domain.file.infrastructure + +import com.weeth.global.config.properties.AwsS3Properties +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class FileAccessUrlAdapterTest : + DescribeSpec({ + describe("S3FileAccessUrlAdapter") { + it("storageKey를 S3 URL로 변환한다") { + val awsS3Properties = + AwsS3Properties( + s3 = AwsS3Properties.S3Properties(bucket = "weeth-bucket"), + credentials = AwsS3Properties.CredentialsProperties(accessKey = "test", secretKey = "test"), + region = AwsS3Properties.RegionProperties(static = "ap-northeast-2"), + ) + val adapter = S3FileAccessUrlAdapter(awsS3Properties) + + val result = adapter.resolve("POST/2026-02/file.png") + + result shouldBe "https://weeth-bucket.s3.ap-northeast-2.amazonaws.com/POST/2026-02/file.png" + } + } + + describe("CdnFileAccessUrlAdapter") { + it("cdn base url이 있으면 CDN URL로 변환한다") { + val adapter = CdnFileAccessUrlAdapter("https://cdn.example.com") + + val result = adapter.resolve("POST/2026-02/file.png") + + result shouldBe "https://cdn.example.com/POST/2026-02/file.png" + } + + it("cdn base url이 없으면 storageKey를 그대로 반환한다") { + val adapter = CdnFileAccessUrlAdapter("") + + val result = adapter.resolve("POST/2026-02/file.png") + + result shouldBe "POST/2026-02/file.png" + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapterTest.kt b/src/test/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapterTest.kt new file mode 100644 index 00000000..bc95373e --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapterTest.kt @@ -0,0 +1,66 @@ +package com.weeth.domain.file.infrastructure + +import com.weeth.domain.file.application.exception.PresignedUrlGenerationException +import com.weeth.domain.file.application.exception.UnsupportedFileExtensionException +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.global.config.properties.AwsS3Properties +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldStartWith +import io.mockk.every +import io.mockk.mockk +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import java.net.URL + +class S3FileUploadUrlAdapterTest : + DescribeSpec({ + describe("generateUploadUrl") { + val awsS3Properties = + AwsS3Properties( + s3 = AwsS3Properties.S3Properties(bucket = "weeth-bucket"), + credentials = AwsS3Properties.CredentialsProperties(accessKey = "test", secretKey = "test"), + region = AwsS3Properties.RegionProperties(static = "ap-northeast-2"), + ) + + it("ownerType/fileName 기반 storageKey와 presigned URL을 반환한다") { + val s3Presigner = mockk() + val presignedRequest = mockk() + val adapter = S3FileUploadUrlAdapter(s3Presigner, awsS3Properties, 5) + + every { s3Presigner.presignPutObject(any()) } returns presignedRequest + every { presignedRequest.url() } returns URL("https://presigned.example.com/upload") + + val result = adapter.generateUploadUrl(FileOwnerType.POST, "file.png") + + result.fileName shouldBe "file.png" + result.storageKey shouldStartWith "POST/" + result.storageKey shouldContain "_file.png" + result.url shouldBe "https://presigned.example.com/upload" + } + + it("presigner 오류가 발생하면 PresignedUrlGenerationException으로 변환한다") { + val s3Presigner = mockk() + val adapter = S3FileUploadUrlAdapter(s3Presigner, awsS3Properties, 5) + + every { s3Presigner.presignPutObject(any()) } throws + RuntimeException("s3 unavailable") + + shouldThrow { + adapter.generateUploadUrl(FileOwnerType.POST, "file.png") + } + } + + it("허용되지 않은 확장자는 URL 생성 전에 차단한다") { + val s3Presigner = mockk() + val adapter = S3FileUploadUrlAdapter(s3Presigner, awsS3Properties, 5) + + shouldThrow { + adapter.generateUploadUrl(FileOwnerType.POST, "file.exe") + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt b/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt index 8c1b03d0..f68798ce 100644 --- a/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt @@ -1,28 +1,34 @@ package com.weeth.domain.schedule.fixture +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.schedule.domain.entity.Event -import com.weeth.domain.schedule.domain.entity.Meeting +import org.springframework.test.util.ReflectionTestUtils import java.time.LocalDateTime object ScheduleTestFixture { - fun createEvent(): Event = - Event - .builder() - .title("Test Meeting") - .location("Test Location") - .start(LocalDateTime.now()) - .end(LocalDateTime.now().plusDays(2)) - .cardinal(1) - .build() - - fun createMeeting(): Meeting = - Meeting - .builder() - .title("Test Meeting") - .location("Test Location") - .start(LocalDateTime.now()) - .end(LocalDateTime.now().plusDays(2)) - .code(1234) - .cardinal(1) - .build() + fun createEvent( + id: Long = 0L, + club: Club = ClubTestFixture.createClub(), + title: String = "Test Event", + content: String = "Test Content", + location: String = "Test Location", + cardinal: Int = 1, + start: LocalDateTime = LocalDateTime.of(2026, 3, 1, 10, 0), + end: LocalDateTime = LocalDateTime.of(2026, 3, 1, 12, 0), + ): Event { + val event = + Event.create( + club = club, + title = title, + content = content, + location = location, + cardinal = cardinal, + start = start, + end = end, + user = null, + ) + if (id != 0L) ReflectionTestUtils.setField(event, "id", id) + return event + } } diff --git a/src/test/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCaseTest.kt new file mode 100644 index 00000000..5f32e485 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCaseTest.kt @@ -0,0 +1,328 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.session.application.dto.request.SessionCreateRequest +import com.weeth.domain.session.application.exception.RecurrenceEndDateBeforeStartException +import com.weeth.domain.session.application.exception.RecurrenceEndDateExceedsMaxException +import com.weeth.domain.session.application.exception.RecurrenceEndDateRequiredException +import com.weeth.domain.session.application.mapper.SessionMapper +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.SessionGroup +import com.weeth.domain.session.domain.enums.RecurrenceType +import com.weeth.domain.session.domain.repository.SessionGroupRepository +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.domain.service.RecurringSessionPolicy +import com.weeth.domain.session.fixture.SessionTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import java.time.LocalDate +import java.time.LocalDateTime + +class CreateSessionUseCaseTest : + DescribeSpec({ + val sessionRepository = mockk() + val attendanceRepository = mockk() + val sessionGroupRepository = mockk() + val userReader = mockk() + val cardinalReader = mockk() + val clubReader = mockk() + val clubMemberCardinalReader = mockk() + val clubPermissionPolicy = mockk(relaxed = true) + val recurringSessionPolicy = RecurringSessionPolicy() + val sessionMapper = SessionMapper(recurringSessionPolicy) + + val useCase = + CreateSessionUseCase( + sessionRepository = sessionRepository, + attendanceRepository = attendanceRepository, + sessionGroupRepository = sessionGroupRepository, + userReader = userReader, + cardinalReader = cardinalReader, + sessionMapper = sessionMapper, + clubReader = clubReader, + clubMemberCardinalReader = clubMemberCardinalReader, + clubPermissionPolicy = clubPermissionPolicy, + recurringSessionPolicy = recurringSessionPolicy, + ) + + val clubId = 1L + val userId = 10L + val club = ClubTestFixture.createClub(id = clubId) + val user = UserTestFixture.createActiveUser1() + val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 1, club = club) + + beforeTest { + clearMocks( + sessionRepository, + attendanceRepository, + sessionGroupRepository, + userReader, + cardinalReader, + clubReader, + clubMemberCardinalReader, + clubPermissionPolicy, + ) + every { clubReader.getClubById(clubId) } returns club + every { userReader.getById(userId) } returns user + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 1) } returns cardinal + every { clubMemberCardinalReader.findAllByClubIdAndCardinalNumber(clubId, 1, MemberStatus.ACTIVE) } returns + emptyList() + every { sessionRepository.save(any()) } answers { firstArg() } + every { sessionRepository.saveAll(any>()) } answers { firstArg() } + every { sessionGroupRepository.save(any()) } answers { firstArg() } + every { attendanceRepository.saveAll(any>()) } answers { firstArg() } + } + + describe("create") { + context("단일 세션 생성 (recurrenceType = null)") { + it("세션 1개와 출석 레코드를 생성한다") { + val request = + SessionCreateRequest( + title = "1차 정기모임", + content = "OT", + location = "공학관 401호", + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + recurrenceType = null, + recurrenceEndDate = null, + ) + + useCase.create(clubId, request, userId) + + verify(exactly = 1) { sessionRepository.save(any()) } + verify(exactly = 0) { sessionGroupRepository.save(any()) } + verify(exactly = 0) { sessionRepository.saveAll(any>()) } + } + } + + context("반복 세션 생성 (WEEKLY)") { + it("주간 반복 세션들이 올바르게 생성된다") { + val request = + SessionCreateRequest( + title = "주간 스터디", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 14, 0), // 수요일 + end = LocalDateTime.of(2026, 4, 1, 16, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2026, 4, 22), // 4주차 수요일 + ) + val sessionsSlot = slot>() + + every { sessionRepository.saveAll(capture(sessionsSlot)) } answers { firstArg() } + + useCase.create(clubId, request, userId) + + verify(exactly = 1) { sessionGroupRepository.save(any()) } + sessionsSlot.captured.size shouldBe 4 + } + + it("멤버가 있으면 세션 수 × 멤버 수 만큼 출석 레코드가 생성된다") { + val member = ClubMemberTestFixture.createActiveMember(club = club) + val memberCardinal = mockk(relaxed = true) + every { memberCardinal.clubMember } returns member + every { + clubMemberCardinalReader.findAllByClubIdAndCardinalNumber(clubId, 1, MemberStatus.ACTIVE) + } returns listOf(memberCardinal) + + val request = + SessionCreateRequest( + title = "주간 스터디", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 14, 0), + end = LocalDateTime.of(2026, 4, 1, 16, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2026, 4, 15), // 3주 + ) + val attendancesSlot = slot>() + + every { attendanceRepository.saveAll(capture(attendancesSlot)) } answers { firstArg() } + + useCase.create(clubId, request, userId) + + // 3주 × 1명 = 3개 출석 레코드 + attendancesSlot.captured.size shouldBe 3 + } + } + + context("반복 세션 생성 (MONTHLY)") { + it("월간 반복 세션들이 올바르게 생성된다") { + val request = + SessionCreateRequest( + title = "월례 회의", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 1, 31, 10, 0), + end = LocalDateTime.of(2026, 1, 31, 12, 0), + recurrenceType = RecurrenceType.MONTHLY, + recurrenceEndDate = LocalDate.of(2026, 4, 30), + ) + val sessionsSlot = slot>() + + every { sessionRepository.saveAll(capture(sessionsSlot)) } answers { firstArg() } + + useCase.create(clubId, request, userId) + + val sessions = sessionsSlot.captured + sessions.size shouldBe 4 + + sessions[0].start.toLocalDate() shouldBe LocalDate.of(2026, 1, 31) + sessions[1].start.toLocalDate() shouldBe LocalDate.of(2026, 2, 28) + sessions[2].start.toLocalDate() shouldBe LocalDate.of(2026, 3, 31) + sessions[3].start.toLocalDate() shouldBe LocalDate.of(2026, 4, 30) + } + } + + context("자정을 넘는 반복 세션 (22:00~02:00)") { + it("end 날짜가 start 다음날로 설정된다") { + val request = + SessionCreateRequest( + title = "야간 스터디", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 22, 0), + end = LocalDateTime.of(2026, 4, 2, 2, 0), // 다음날 새벽 2시 + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2026, 4, 15), + ) + val sessionsSlot = slot>() + + every { sessionRepository.saveAll(capture(sessionsSlot)) } answers { firstArg() } + + useCase.create(clubId, request, userId) + + val sessions = sessionsSlot.captured + sessions.size shouldBe 3 + + // 각 세션의 start는 해당 날짜 22시, end는 다음날 02시 + sessions[0].start shouldBe LocalDateTime.of(2026, 4, 1, 22, 0) + sessions[0].end shouldBe LocalDateTime.of(2026, 4, 2, 2, 0) + sessions[1].start shouldBe LocalDateTime.of(2026, 4, 8, 22, 0) + sessions[1].end shouldBe LocalDateTime.of(2026, 4, 9, 2, 0) + sessions[2].start shouldBe LocalDateTime.of(2026, 4, 15, 22, 0) + sessions[2].end shouldBe LocalDateTime.of(2026, 4, 16, 2, 0) + } + } + + context("검증 실패") { + it("존재하지 않는 기수이면 예외를 던진다") { + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 99) } returns null + + val request = + SessionCreateRequest( + title = "세션", + content = null, + location = null, + cardinal = 99, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + recurrenceType = null, + recurrenceEndDate = null, + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + + it("반복 타입이 있는데 종료일이 없으면 예외를 던진다") { + val request = + SessionCreateRequest( + title = "반복 세션", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = null, + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + + it("반복 종료일이 시작일보다 이전이면 예외를 던진다") { + val request = + SessionCreateRequest( + title = "반복 세션", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 10, 10, 0), + end = LocalDateTime.of(2026, 4, 10, 12, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2026, 4, 1), + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + + it("반복 종료일이 시작일 기준 1년을 초과하면 예외를 던진다") { + val request = + SessionCreateRequest( + title = "반복 세션", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2027, 4, 2), // 1년 + 1일 + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + + it("반복 종료일이 시작일 기준 정확히 1년이면 성공한다") { + val request = + SessionCreateRequest( + title = "반복 세션", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2027, 4, 1), // 정확히 1년 + ) + + useCase.create(clubId, request, userId) + + verify(exactly = 1) { sessionGroupRepository.save(any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCaseTest.kt new file mode 100644 index 00000000..5c1cbc55 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCaseTest.kt @@ -0,0 +1,359 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.session.application.exception.ClosedSessionIncludedException +import com.weeth.domain.session.application.exception.SessionGroupNotFoundException +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.enums.UpdateScope +import com.weeth.domain.session.domain.repository.SessionGroupRepository +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDateTime +import java.util.Optional + +class DeleteSessionUseCaseTest : + DescribeSpec({ + val sessionRepository = mockk() + val attendanceRepository = mockk() + val sessionGroupRepository = mockk() + val clubPermissionPolicy = mockk(relaxed = true) + + val useCase = + DeleteSessionUseCase(sessionRepository, attendanceRepository, sessionGroupRepository, clubPermissionPolicy) + + val clubId = 1L + val userId = 10L + val club = ClubTestFixture.createClub(id = clubId) + + beforeTest { + clearMocks(sessionRepository, attendanceRepository, sessionGroupRepository, clubPermissionPolicy) + every { attendanceRepository.findAllBySessionAndClubMemberMemberStatusWithLock(any(), any()) } returns + emptyList() + every { attendanceRepository.findAllBySessionInAndClubMemberMemberStatusWithLock(any(), any()) } returns + emptyList() + every { attendanceRepository.deleteAllBySession(any()) } just Runs + every { attendanceRepository.deleteAllBySessionIn(any()) } just Runs + every { sessionRepository.delete(any()) } just Runs + every { sessionRepository.deleteAll(any>()) } just Runs + every { sessionGroupRepository.delete(any()) } just Runs + } + + describe("delete") { + context("존재하지 않는 세션") { + it("예외를 던진다") { + every { sessionRepository.findByIdWithLock(99L) } returns null + + shouldThrow { + useCase.delete(clubId, 99L, userId) + } + } + } + + context("다른 클럽의 세션") { + it("예외를 던진다") { + val otherClub = ClubTestFixture.createClub(id = 999L) + val session = SessionTestFixture.createSession(id = 1L, club = otherClub) + every { sessionRepository.findByIdWithLock(1L) } returns session + + shouldThrow { + useCase.delete(clubId, 1L, userId) + } + } + } + + context("단일 세션 삭제") { + it("세션과 출석 레코드를 삭제한다") { + val session = SessionTestFixture.createSession(id = 1L, club = club) + every { sessionRepository.findByIdWithLock(1L) } returns session + + useCase.delete(clubId, 1L, userId) + + verify(exactly = 1) { attendanceRepository.deleteAllBySession(session) } + verify(exactly = 1) { sessionRepository.delete(session) } + } + } + + context("반복 세션 THIS_ONLY 삭제") { + it("해당 세션만 삭제하고 그룹은 유지한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session = SessionTestFixture.createSession(id = 1L, club = club, sessionGroup = group) + every { sessionRepository.findByIdWithLock(1L) } returns session + every { sessionGroupRepository.findByIdWithLock(1L) } returns group + every { sessionRepository.countBySessionGroup(group) } returns 3L + + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_ONLY) + + verify(exactly = 1) { sessionRepository.delete(session) } + verify(exactly = 0) { sessionGroupRepository.delete(any()) } + } + + it("마지막 세션 삭제 시 그룹도 함께 삭제한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session = SessionTestFixture.createSession(id = 1L, club = club, sessionGroup = group) + every { sessionRepository.findByIdWithLock(1L) } returns session + every { sessionGroupRepository.findByIdWithLock(1L) } returns group + every { sessionRepository.countBySessionGroup(group) } returns 0L + + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_ONLY) + + verify(exactly = 1) { sessionGroupRepository.delete(group) } + } + } + + context("반복 세션 THIS_AND_FUTURE 삭제") { + it("해당 세션부터 이후 모든 세션을 삭제한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + val futureSessions = listOf(session1, session2) + + every { sessionRepository.findByIdWithLock(1L) } returns session1 + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session1.start) + } returns futureSessions + every { sessionGroupRepository.findByIdWithLock(1L) } returns group + every { sessionRepository.countBySessionGroup(group) } returns 2L + + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_AND_FUTURE) + + verify(exactly = 1) { attendanceRepository.deleteAllBySessionIn(futureSessions) } + verify(exactly = 1) { sessionRepository.deleteAll(futureSessions) } + } + + it("CLOSED 세션 포함 시 force=false이면 예외를 던진다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val openSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val closedSession = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns openSession + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock( + group, + openSession.start, + ) + } returns listOf(openSession, closedSession) + + shouldThrow { + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_AND_FUTURE, force = false) + } + } + + it("CLOSED 세션 포함 시 force=true이면 정상 삭제된다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val openSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val closedSession = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns openSession + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock( + group, + openSession.start, + ) + } returns listOf(openSession, closedSession) + every { sessionGroupRepository.findByIdWithLock(1L) } returns group + every { sessionRepository.countBySessionGroup(group) } returns 2L + + shouldNotThrowAny { + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_AND_FUTURE, force = true) + } + + verify(exactly = 1) { sessionRepository.deleteAll(listOf(openSession, closedSession)) } + } + + it("모든 세션을 삭제하면 그룹도 함께 삭제된다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns session1 + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session1.start) + } returns listOf(session1, session2) + every { sessionGroupRepository.findByIdWithLock(1L) } returns group + every { sessionRepository.countBySessionGroup(group) } returns 0L + + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_AND_FUTURE) + + verify(exactly = 1) { sessionGroupRepository.delete(group) } + } + } + } + + describe("deleteGroup") { + context("존재하지 않는 그룹") { + it("예외를 던진다") { + every { sessionGroupRepository.findById(99L) } returns Optional.empty() + + shouldThrow { + useCase.deleteGroup(clubId, 99L, userId) + } + } + } + + context("다른 클럽의 세션 그룹") { + it("예외를 던진다") { + val otherClub = ClubTestFixture.createClub(id = 999L) + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session = SessionTestFixture.createSession(id = 1L, club = otherClub, sessionGroup = group) + + every { sessionGroupRepository.findById(1L) } returns Optional.of(group) + every { sessionRepository.findAllBySessionGroupWithLock(group) } returns listOf(session) + + shouldThrow { + useCase.deleteGroup(clubId, 1L, userId) + } + } + } + + context("그룹 전체 삭제") { + it("모든 세션과 출석을 삭제하고 그룹을 삭제한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + val sessions = listOf(session1, session2) + + every { sessionGroupRepository.findById(1L) } returns Optional.of(group) + every { sessionRepository.findAllBySessionGroupWithLock(group) } returns sessions + + useCase.deleteGroup(clubId, 1L, userId) + + verify(exactly = 1) { attendanceRepository.deleteAllBySessionIn(sessions) } + verify(exactly = 1) { sessionRepository.deleteAll(sessions) } + verify(exactly = 1) { sessionGroupRepository.delete(group) } + } + } + + context("CLOSED 세션 포함") { + it("force=false이면 예외를 던진다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val closedSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + + every { sessionGroupRepository.findById(1L) } returns Optional.of(group) + every { sessionRepository.findAllBySessionGroupWithLock(group) } returns listOf(closedSession) + + shouldThrow { + useCase.deleteGroup(clubId, 1L, userId, force = false) + } + } + + it("force=true이면 정상 삭제된다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val closedSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + + every { sessionGroupRepository.findById(1L) } returns Optional.of(group) + every { sessionRepository.findAllBySessionGroupWithLock(group) } returns listOf(closedSession) + + shouldNotThrowAny { + useCase.deleteGroup(clubId, 1L, userId, force = true) + } + + verify(exactly = 1) { sessionGroupRepository.delete(group) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCaseTest.kt new file mode 100644 index 00000000..c6488900 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCaseTest.kt @@ -0,0 +1,292 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.session.application.dto.request.SessionUpdateRequest +import com.weeth.domain.session.application.exception.ClosedSessionIncludedException +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.enums.UpdateScope +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.domain.service.RecurringSessionPolicy +import com.weeth.domain.session.fixture.SessionTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import java.time.LocalDateTime +import java.time.LocalTime + +class UpdateSessionUseCaseTest : + DescribeSpec({ + val sessionRepository = mockk() + val userReader = mockk() + val clubPermissionPolicy = mockk(relaxed = true) + + val recurringSessionPolicy = RecurringSessionPolicy() + val useCase = UpdateSessionUseCase(sessionRepository, userReader, clubPermissionPolicy, recurringSessionPolicy) + + val clubId = 1L + val userId = 10L + val club = ClubTestFixture.createClub(id = clubId) + val user = UserTestFixture.createActiveUser1() + + beforeTest { + clearMocks(sessionRepository, userReader, clubPermissionPolicy) + every { userReader.getById(userId) } returns user + } + + describe("update") { + context("존재하지 않는 세션") { + it("예외를 던진다") { + every { sessionRepository.findByIdWithLock(99L) } returns null + val request = SessionUpdateRequest("변경", null, null, null, null) + + shouldThrow { + useCase.update(clubId, 99L, request, userId) + } + } + } + + context("다른 클럽의 세션") { + it("예외를 던진다") { + val otherClub = ClubTestFixture.createClub(id = 999L) + val session = SessionTestFixture.createSession(id = 1L, club = otherClub) + every { sessionRepository.findByIdWithLock(1L) } returns session + + val request = SessionUpdateRequest("변경", null, null, null, null) + + shouldThrow { + useCase.update(clubId, 1L, request, userId) + } + } + } + + context("THIS_ONLY 수정") { + it("단일 세션의 제목과 내용을 수정한다") { + val session = SessionTestFixture.createSession(id = 1L, club = club) + every { sessionRepository.findByIdWithLock(1L) } returns session + + val request = SessionUpdateRequest("변경된 제목", "변경된 내용", null, null, null) + + useCase.update(clubId, 1L, request, userId) + + session.title shouldBe "변경된 제목" + session.content shouldBe "변경된 내용" + } + + it("반복 세션이어도 THIS_ONLY이면 해당 세션만 수정한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session = SessionTestFixture.createSession(id = 1L, club = club, sessionGroup = group) + every { sessionRepository.findByIdWithLock(1L) } returns session + + val request = SessionUpdateRequest("개별 변경", null, null, null, null) + + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_ONLY) + + session.title shouldBe "개별 변경" + } + } + + context("THIS_AND_FUTURE 수정") { + it("이후 모든 세션의 시간 부분만 변경된다 (날짜는 유지)") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns session1 + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session1.start) + } returns listOf(session1, session2) + + val request = + SessionUpdateRequest( + title = "통합 수정", + content = null, + location = null, + start = LocalDateTime.of(2026, 4, 1, 14, 0), // 시간만 14시로 변경 + end = LocalDateTime.of(2026, 4, 1, 16, 0), + ) + + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_AND_FUTURE) + + // 날짜는 각각 유지, 시간만 변경 + session1.start shouldBe LocalDateTime.of(2026, 4, 1, 14, 0) + session1.end shouldBe LocalDateTime.of(2026, 4, 1, 16, 0) + session2.start shouldBe LocalDateTime.of(2026, 4, 8, 14, 0) + session2.end shouldBe LocalDateTime.of(2026, 4, 8, 16, 0) + + // 제목도 일괄 변경 + session1.title shouldBe "통합 수정" + session2.title shouldBe "통합 수정" + } + + it("CLOSED 세션 포함 시 force=false이면 예외를 던진다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val openSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val closedSession = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns openSession + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock( + group, + openSession.start, + ) + } returns listOf(openSession, closedSession) + + val request = SessionUpdateRequest("수정", null, null, null, null) + + shouldThrow { + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_AND_FUTURE, force = false) + } + } + + it("CLOSED 세션 포함 시 force=true이면 정상 수정된다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val openSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val closedSession = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns openSession + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock( + group, + openSession.start, + ) + } returns listOf(openSession, closedSession) + + val request = SessionUpdateRequest("강제 수정", null, null, null, null) + + shouldNotThrowAny { + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_AND_FUTURE, force = true) + } + + openSession.title shouldBe "강제 수정" + closedSession.title shouldBe "강제 수정" + } + + it("시간 변경이 null이면 각 세션의 기존 시간을 유지한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns session1 + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session1.start) + } returns listOf(session1, session2) + + // 시간은 null, 제목만 변경 + val request = SessionUpdateRequest("제목만 변경", null, null, null, null) + + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_AND_FUTURE) + + session1.start.toLocalTime() shouldBe LocalTime.of(10, 0) + session2.start.toLocalTime() shouldBe LocalTime.of(10, 0) + } + + it("자정을 넘는 시간으로 변경하면 end 날짜가 다음날로 설정된다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns session1 + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session1.start) + } returns listOf(session1, session2) + + // 22:00~02:00(다음날)로 변경 + val request = + SessionUpdateRequest( + title = null, + content = null, + location = null, + start = LocalDateTime.of(2026, 4, 1, 22, 0), + end = LocalDateTime.of(2026, 4, 2, 2, 0), + ) + + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_AND_FUTURE) + + session1.start shouldBe LocalDateTime.of(2026, 4, 1, 22, 0) + session1.end shouldBe LocalDateTime.of(2026, 4, 2, 2, 0) + session2.start shouldBe LocalDateTime.of(2026, 4, 8, 22, 0) + session2.end shouldBe LocalDateTime.of(2026, 4, 9, 2, 0) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt new file mode 100644 index 00000000..c162e17a --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt @@ -0,0 +1,125 @@ +package com.weeth.domain.session.application.usecase.query + +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.session.application.dto.response.SessionInfosResponse +import com.weeth.domain.session.application.dto.response.SessionResponse +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.application.mapper.SessionMapper +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GetSessionQueryServiceTest : + DescribeSpec({ + val sessionRepository = mockk() + val cardinalReader = mockk() + val clubMemberPolicy = mockk(relaxed = true) + val clubPermissionPolicy = mockk(relaxed = true) + val sessionMapper = mockk() + val queryService = + GetSessionQueryService( + sessionRepository, + cardinalReader, + clubMemberPolicy, + clubPermissionPolicy, + sessionMapper, + ) + + val clubId = 1L + val userId = 10L + + beforeTest { + clearMocks(sessionRepository, cardinalReader, clubMemberPolicy, clubPermissionPolicy, sessionMapper) + } + + describe("findSession") { + it("존재하지 않는 세션이면 예외를 던진다") { + every { sessionRepository.findByIdAndClubId(99L, clubId) } returns null + + shouldThrow { + queryService.findSession(clubId, userId, 99L) + } + } + + it("어드민/리드는 admin 응답을 반환한다") { + val session = SessionTestFixture.createSession() + val adminMember = ClubMemberTestFixture.createAdminMember() + val response = mockk() + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns adminMember + every { sessionRepository.findByIdAndClubId(1L, clubId) } returns session + every { sessionMapper.toAdminResponse(session) } returns response + + val result = queryService.findSession(clubId, userId, 1L) + + result shouldBe response + verify(exactly = 0) { sessionMapper.toResponse(any()) } + } + + it("일반 멤버는 일반 응답을 반환한다") { + val session = SessionTestFixture.createSession() + val member = ClubMemberTestFixture.createActiveMember() + val response = mockk() + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { sessionRepository.findByIdAndClubId(1L, clubId) } returns session + every { sessionMapper.toResponse(session) } returns response + + val result = queryService.findSession(clubId, userId, 1L) + + result shouldBe response + verify(exactly = 0) { sessionMapper.toAdminResponse(any()) } + } + } + + describe("findSessionInfos") { + it("cardinal이 null이면 클럽 전체 세션을 반환한다") { + val sessions = listOf(SessionTestFixture.createSession()) + val response = mockk() + + every { sessionRepository.findAllByClubIdOrderByStartDesc(clubId) } returns sessions + every { sessionMapper.toSingleGroupResponse(any()) } returns mockk(relaxed = true) + every { sessionMapper.toInfos(any(), any()) } returns response + + val result = queryService.findSessionInfos(clubId, userId, null) + + result shouldBe response + verify(exactly = 0) { cardinalReader.findByClubIdAndCardinalNumber(any(), any()) } + } + + it("cardinal이 지정되면 해당 기수의 세션만 반환한다") { + val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 3) + val sessions = listOf(SessionTestFixture.createSession(cardinal = 3)) + val response = mockk() + + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 3) } returns cardinal + every { sessionRepository.findAllByClubIdAndCardinalOrderByStartDesc(clubId, 3) } returns sessions + every { sessionMapper.toSingleGroupResponse(any()) } returns mockk(relaxed = true) + every { sessionMapper.toInfos(any(), any()) } returns response + + val result = queryService.findSessionInfos(clubId, userId, 3) + + result shouldBe response + } + + it("존재하지 않는 기수를 요청하면 예외를 던진다") { + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 99) } returns null + + shouldThrow { + queryService.findSessionInfos(clubId, userId, 99) + } + verify(exactly = 0) { sessionRepository.findAllByClubIdAndCardinalOrderByStartDesc(any(), any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/domain/entity/SessionTest.kt b/src/test/kotlin/com/weeth/domain/session/domain/entity/SessionTest.kt new file mode 100644 index 00000000..c2efacc8 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/domain/entity/SessionTest.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.session.domain.entity + +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class SessionTest : + StringSpec({ + "close는 status를 CLOSED로 변경한다" { + val session = SessionTestFixture.createSession(status = SessionStatus.OPEN) + + session.close() + + session.status shouldBe SessionStatus.CLOSED + } + + "이미 CLOSED 상태에서 close 호출 시 예외가 발생한다" { + val session = SessionTestFixture.createSession(status = SessionStatus.CLOSED) + + shouldThrow { + session.close() + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicyTest.kt b/src/test/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicyTest.kt new file mode 100644 index 00000000..d0afff60 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicyTest.kt @@ -0,0 +1,188 @@ +package com.weeth.domain.session.domain.service + +import com.weeth.domain.session.domain.enums.RecurrenceType +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +class RecurringSessionPolicyTest : + StringSpec({ + val policy = RecurringSessionPolicy() + val defaultStartTime = LocalTime.of(14, 0) + val defaultEndTime = LocalTime.of(16, 0) + + fun startOf(date: LocalDate): LocalDateTime = LocalDateTime.of(date, defaultStartTime) + + fun endOf(date: LocalDate): LocalDateTime = LocalDateTime.of(date, defaultEndTime) + + // === DAILY === + + "DAILY: 시작일부터 종료일까지 매일 스케줄을 생성한다" { + val start = LocalDate.of(2026, 3, 1) + val end = LocalDate.of(2026, 3, 5) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.DAILY, end) + + schedules shouldHaveSize 5 + schedules[0].first shouldBe startOf(LocalDate.of(2026, 3, 1)) + schedules[0].second shouldBe endOf(LocalDate.of(2026, 3, 1)) + schedules[4].first shouldBe startOf(LocalDate.of(2026, 3, 5)) + schedules[4].second shouldBe endOf(LocalDate.of(2026, 3, 5)) + } + + "DAILY: 시작일과 종료일이 같으면 1개만 생성한다" { + val date = LocalDate.of(2026, 3, 1) + + val schedules = policy.calculateSchedules(startOf(date), endOf(date), RecurrenceType.DAILY, date) + + schedules shouldHaveSize 1 + schedules[0].first shouldBe startOf(date) + } + + // === WEEKLY === + + "WEEKLY: 매주 같은 요일에 스케줄을 생성한다" { + val start = LocalDate.of(2026, 3, 4) // 수요일 + val end = LocalDate.of(2026, 3, 25) // 수요일 + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.WEEKLY, end) + + schedules shouldHaveSize 4 + schedules[0].first shouldBe startOf(LocalDate.of(2026, 3, 4)) + schedules[1].first shouldBe startOf(LocalDate.of(2026, 3, 11)) + schedules[2].first shouldBe startOf(LocalDate.of(2026, 3, 18)) + schedules[3].first shouldBe startOf(LocalDate.of(2026, 3, 25)) + } + + "WEEKLY: 종료일이 정확히 다음 주 전이면 1개만 생성한다" { + val start = LocalDate.of(2026, 3, 4) // 수요일 + val end = LocalDate.of(2026, 3, 10) // 화요일 (다음 수요일 전) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.WEEKLY, end) + + schedules shouldHaveSize 1 + schedules[0].first shouldBe startOf(start) + } + + // === MONTHLY === + + "MONTHLY: 매월 같은 일자에 스케줄을 생성한다" { + val start = LocalDate.of(2026, 1, 15) + val end = LocalDate.of(2026, 4, 15) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.MONTHLY, end) + + schedules shouldHaveSize 4 + schedules[0].first shouldBe startOf(LocalDate.of(2026, 1, 15)) + schedules[1].first shouldBe startOf(LocalDate.of(2026, 2, 15)) + schedules[2].first shouldBe startOf(LocalDate.of(2026, 3, 15)) + schedules[3].first shouldBe startOf(LocalDate.of(2026, 4, 15)) + } + + "MONTHLY: 31일 시작이면 짧은 달은 말일로 조정된다" { + val start = LocalDate.of(2026, 1, 31) + val end = LocalDate.of(2026, 4, 30) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.MONTHLY, end) + + // 1/31 → 2/28 → 3/31 → 4/30 (원본 기준 plusMonths) + schedules shouldHaveSize 4 + schedules[0].first.toLocalDate() shouldBe LocalDate.of(2026, 1, 31) + schedules[1].first.toLocalDate() shouldBe LocalDate.of(2026, 2, 28) + schedules[2].first.toLocalDate() shouldBe LocalDate.of(2026, 3, 31) + schedules[3].first.toLocalDate() shouldBe LocalDate.of(2026, 4, 30) + } + + "MONTHLY: 체이닝 방식과 다르게 원본 기준으로 계산된다" { + // 체이닝: 1/31 → 2/28 → 3/28 (X) + // 원본 기준: 1/31 → 2/28 → 3/31 (O) + val start = LocalDate.of(2026, 1, 31) + val end = LocalDate.of(2026, 3, 31) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.MONTHLY, end) + + schedules shouldHaveSize 3 + schedules[2].first.toLocalDate() shouldBe LocalDate.of(2026, 3, 31) + } + + // === duration (자정 넘김) === + + "자정을 넘기는 세션도 duration 기반으로 정상 처리된다" { + val date = LocalDate.of(2026, 3, 1) + val startDateTime = LocalDateTime.of(date, LocalTime.of(22, 0)) + val endDateTime = LocalDateTime.of(date.plusDays(1), LocalTime.of(2, 0)) // 4시간 + + val schedules = + policy.calculateSchedules( + startDateTime, + endDateTime, + RecurrenceType.DAILY, + date.plusDays(2), + ) + + schedules shouldHaveSize 3 + // 첫째 날: 3/1 22:00 ~ 3/2 02:00 + schedules[0].first shouldBe LocalDateTime.of(2026, 3, 1, 22, 0) + schedules[0].second shouldBe LocalDateTime.of(2026, 3, 2, 2, 0) + // 둘째 날: 3/2 22:00 ~ 3/3 02:00 + schedules[1].first shouldBe LocalDateTime.of(2026, 3, 2, 22, 0) + schedules[1].second shouldBe LocalDateTime.of(2026, 3, 3, 2, 0) + } + + // === 경계 조건 === + + "종료일이 시작일보다 이전이면 빈 리스트를 반환한다" { + val start = LocalDate.of(2026, 3, 10) + val end = LocalDate.of(2026, 3, 1) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.WEEKLY, end) + + schedules.shouldBeEmpty() + } + + "MONTHLY: 종료일이 다음 달 전이면 시작일만 포함된다" { + val start = LocalDate.of(2026, 3, 15) + val end = LocalDate.of(2026, 4, 14) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.MONTHLY, end) + + schedules shouldHaveSize 1 + schedules[0].first shouldBe startOf(start) + } + + // === buildRecurrenceDescription === + + "DAILY: '매일 N시' 형식으로 반환한다" { + val result = + policy.buildRecurrenceDescription( + RecurrenceType.DAILY, + LocalTime.of(14, 0), + LocalDate.of(2026, 3, 4), + ) + result shouldBe "매일 14시" + } + + "WEEKLY: '매주 X요일 N시' 형식으로 반환한다" { + val result = + policy.buildRecurrenceDescription( + RecurrenceType.WEEKLY, + LocalTime.of(10, 0), + LocalDate.of(2026, 3, 4), // 수요일 + ) + result shouldBe "매주 수요일 10시" + } + + "MONTHLY: '매월 N일 N시' 형식으로 반환한다" { + val result = + policy.buildRecurrenceDescription( + RecurrenceType.MONTHLY, + LocalTime.of(19, 0), + LocalDate.of(2026, 3, 15), + ) + result shouldBe "매월 15일 19시" + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt new file mode 100644 index 00000000..04ccbe4a --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt @@ -0,0 +1,99 @@ +package com.weeth.domain.session.fixture + +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.SessionGroup +import com.weeth.domain.session.domain.enums.RecurrenceType +import com.weeth.domain.session.domain.enums.SessionStatus +import org.springframework.test.util.ReflectionTestUtils +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +object SessionTestFixture { + fun createSession( + id: Long = 0L, + club: Club = ClubTestFixture.createClub(), + title: String = "Test Session", + content: String = "Test Content", + location: String = "Test Location", + cardinal: Int = 1, + code: Int = 123456, + status: SessionStatus = SessionStatus.OPEN, + start: LocalDateTime = LocalDateTime.of(2026, 3, 1, 10, 0), + end: LocalDateTime = LocalDateTime.of(2026, 3, 1, 12, 0), + sessionGroup: SessionGroup? = null, + ): Session { + val session = + Session( + club = club, + title = title, + content = content, + location = location, + cardinal = cardinal, + code = code, + status = status, + start = start, + end = end, + sessionGroup = sessionGroup, + ) + if (id != 0L) ReflectionTestUtils.setField(session, "id", id) + return session + } + + fun createSessionGroup( + id: Long = 0L, + title: String = "반복 세션", + recurrenceType: RecurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate: LocalDate = LocalDate.of(2026, 6, 30), + cardinal: Int = 1, + startTime: LocalTime = LocalTime.of(10, 0), + endTime: LocalTime = LocalTime.of(12, 0), + ): SessionGroup { + val group = + SessionGroup( + title = title, + recurrenceType = recurrenceType, + recurrenceEndDate = recurrenceEndDate, + cardinal = cardinal, + startTime = startTime, + endTime = endTime, + ) + if (id != 0L) ReflectionTestUtils.setField(group, "id", id) + return group + } + + fun createOneDaySession( + date: LocalDate, + cardinal: Int, + code: Int, + title: String, + club: Club = ClubTestFixture.createClub(), + ): Session = + Session( + club = club, + title = title, + location = "Test Location", + start = date.atTime(10, 0), + end = date.atTime(12, 0), + code = code, + cardinal = cardinal, + ) + + fun createInProgressSession( + cardinal: Int, + code: Int, + title: String, + club: Club = ClubTestFixture.createClub(), + ): Session = + Session( + club = club, + title = title, + location = "Test Location", + start = LocalDateTime.now().minusMinutes(5), + end = LocalDateTime.now().plusMinutes(5), + code = code, + cardinal = cardinal, + ) +} diff --git a/src/test/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryServiceTest.kt new file mode 100644 index 00000000..8a632569 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryServiceTest.kt @@ -0,0 +1,36 @@ +package com.weeth.domain.university.application.usecase.query + +import com.weeth.domain.university.application.exception.CareerNetApiException +import com.weeth.domain.university.application.mapper.UniversityMapper +import com.weeth.domain.university.domain.port.UniversityInfoPort +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk + +class GetUniversityQueryServiceTest : + DescribeSpec({ + val universityInfoPort = mockk() + val universityMapper = mockk() + val queryService = GetUniversityQueryService(universityInfoPort, universityMapper) + + describe("getSchools") { + context("커리어넷 API 오류 시") { + it("CareerNetApiException을 전파한다") { + every { universityInfoPort.getSchools() } throws CareerNetApiException() + + shouldThrow { queryService.getSchools() } + } + } + } + + describe("getMajors") { + context("커리어넷 API 오류 시") { + it("CareerNetApiException을 전파한다") { + every { universityInfoPort.getMajors() } throws CareerNetApiException() + + shouldThrow { queryService.getMajors() } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityCacheIntegrationTest.kt b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityCacheIntegrationTest.kt new file mode 100644 index 00000000..f780acc9 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityCacheIntegrationTest.kt @@ -0,0 +1,109 @@ +package com.weeth.domain.university.application.usecase.query + +import com.ninjasquad.springmockk.MockkBean +import com.weeth.config.TestContainersConfig +import com.weeth.domain.university.domain.model.MajorData +import com.weeth.domain.university.domain.model.SchoolData +import com.weeth.domain.university.domain.port.UniversityInfoPort +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.verify +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.cache.CacheManager +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles + +/* + [캐시 통합 테스트] + - 실제 Redis(Testcontainers)와 Spring Cache AOP를 사용하여 캐시 동작을 검증합니다. + - UniversityInfoPort는 Mock으로 대체하여 실제 CareerNet API를 호출하지 않습니다. + - 각 테스트 전 캐시를 초기화하여 테스트 간 간섭을 방지합니다. + - 성능 벤치마크는 UniversityRealApiCacheTest에서 실제 API를 사용해 측정합니다. + */ +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class) +class UniversityCacheIntegrationTest( + private val getUniversityQueryService: GetUniversityQueryService, + @MockkBean private val universityInfoPort: UniversityInfoPort, + private val cacheManager: CacheManager, +) : DescribeSpec({ + + val schoolData = (1..20).map { SchoolData("학교$it", "서울") } + val majorData = (1..20).map { MajorData("학과$it", "공학계열") } + + beforeEach { + clearMocks(universityInfoPort) + cacheManager.getCache("schools")?.clear() + cacheManager.getCache("majors")?.clear() + } + + describe("getSchools") { + context("캐시 미스") { + it("UniversityInfoPort를 1번 호출하고 결과를 반환한다") { + every { universityInfoPort.getSchools() } returns schoolData + + val result = getUniversityQueryService.getSchools() + + result shouldHaveSize 20 + verify(exactly = 1) { universityInfoPort.getSchools() } + } + } + + context("캐시 히트") { + it("두 번 호출해도 포트는 1번만 호출된다") { + every { universityInfoPort.getSchools() } returns schoolData + + getUniversityQueryService.getSchools() // cache miss + getUniversityQueryService.getSchools() // cache hit + + verify(exactly = 1) { universityInfoPort.getSchools() } + } + + it("캐시에서 반환한 결과가 포트 응답과 동일하다") { + every { universityInfoPort.getSchools() } returns schoolData + + val first = getUniversityQueryService.getSchools() + val second = getUniversityQueryService.getSchools() + + first shouldBe second + } + } + } + + describe("getMajors") { + context("캐시 미스") { + it("UniversityInfoPort를 1번 호출하고 결과를 반환한다") { + every { universityInfoPort.getMajors() } returns majorData + + val result = getUniversityQueryService.getMajors() + + result shouldHaveSize 20 + verify(exactly = 1) { universityInfoPort.getMajors() } + } + } + + context("캐시 히트") { + it("두 번 호출해도 포트는 1번만 호출된다") { + every { universityInfoPort.getMajors() } returns majorData + + getUniversityQueryService.getMajors() // cache miss + getUniversityQueryService.getMajors() // cache hit + + verify(exactly = 1) { universityInfoPort.getMajors() } + } + + it("캐시에서 반환한 결과가 포트 응답과 동일하다") { + every { universityInfoPort.getMajors() } returns majorData + + val first = getUniversityQueryService.getMajors() + val second = getUniversityQueryService.getMajors() + + first shouldBe second + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityRealApiCacheTest.kt b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityRealApiCacheTest.kt new file mode 100644 index 00000000..971fc54f --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityRealApiCacheTest.kt @@ -0,0 +1,69 @@ +package com.weeth.domain.university.application.usecase.query + +import com.weeth.config.CacheBenchmarkUtil +import com.weeth.config.TestContainersConfig +import io.kotest.common.ExperimentalKotest +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.longs.shouldBeLessThan +import org.junit.jupiter.api.Tag +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.cache.CacheManager +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles + +/* + [실제 CareerNet API 캐시 성능 벤치마크] + - 실제 CareerNetAdapter를 사용하여 캐시 미스/히트 성능 차이를 측정합니다. + - 20번 호출 시 캐싱 없을 때(20 × API)와 캐싱 있을 때(1 × API + 19 × Redis) 비교합니다. + - CAREER_NET_API_KEY 환경변수가 없으면 전체 테스트를 스킵합니다. + - 실행: export $(cat .env | xargs) && ./gradlew test --tests "UniversityRealApiCacheTest" + */ +@Tag("performance") +@OptIn(ExperimentalKotest::class) +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class) +class UniversityRealApiCacheTest( + private val getUniversityQueryService: GetUniversityQueryService, + private val cacheManager: CacheManager, +) : DescribeSpec({ + + val hasRealApiKey = !System.getenv("CAREER_NET_API_KEY").isNullOrBlank() + + beforeEach { + cacheManager.getCache("schools")?.clear() + cacheManager.getCache("majors")?.clear() + } + + describe("getSchools - 실제 API").config(enabled = hasRealApiKey) { + it("캐싱 없이 20번 vs 캐싱 있을 때 20번 성능 비교") { + val result = + CacheBenchmarkUtil.benchmarkRounds( + cacheName = "schools", + rounds = 20, + clearCache = { cacheManager.getCache("schools")?.clear() }, + ) { + getUniversityQueryService.getSchools() + } + println(result) + + result.totalWithCacheMs shouldBeLessThan result.estimatedTotalWithoutCacheMs + } + } + + describe("getMajors - 실제 API").config(enabled = hasRealApiKey) { + it("캐싱 없이 20번 vs 캐싱 있을 때 20번 성능 비교") { + val result = + CacheBenchmarkUtil.benchmarkRounds( + cacheName = "majors", + rounds = 20, + clearCache = { cacheManager.getCache("majors")?.clear() }, + ) { + getUniversityQueryService.getMajors() + } + println(result) + + result.totalWithCacheMs shouldBeLessThan result.estimatedTotalWithoutCacheMs + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/CardinalUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/CardinalUseCaseTest.kt deleted file mode 100644 index b22de9b1..00000000 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/CardinalUseCaseTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.weeth.domain.user.application.usecase - -import com.weeth.domain.user.application.dto.request.CardinalSaveRequest -import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest -import com.weeth.domain.user.application.dto.response.CardinalResponse -import com.weeth.domain.user.application.mapper.CardinalMapper -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.enums.CardinalStatus -import com.weeth.domain.user.domain.service.CardinalGetService -import com.weeth.domain.user.domain.service.CardinalSaveService -import com.weeth.domain.user.fixture.CardinalTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import io.mockk.Runs -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.verify -import java.time.LocalDateTime - -class CardinalUseCaseTest : - DescribeSpec({ - - val cardinalGetService = mockk() - val cardinalSaveService = mockk() - val cardinalMapper = mockk() - val useCase = CardinalUseCase(cardinalGetService, cardinalSaveService, cardinalMapper) - - describe("save") { - context("진행중이 아닌 기수라면") { - it("검증 후 저장만 한다") { - val request = CardinalSaveRequest(7, 2025, 1, false) - val toSave = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - val saved = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - - every { cardinalGetService.validateCardinal(7) } just Runs - every { cardinalMapper.from(request) } returns toSave - every { cardinalSaveService.save(toSave) } returns saved - - useCase.save(request) - - verify { cardinalGetService.validateCardinal(7) } - verify { cardinalSaveService.save(toSave) } - verify(exactly = 0) { cardinalGetService.findInProgress() } - } - } - - context("새 기수가 진행중이라면") { - it("기존 기수는 DONE, 현재기수는 IN_PROGRESS가 된다") { - val request = CardinalSaveRequest(7, 2025, 1, true) - val oldCardinal = - CardinalTestFixture.createCardinalInProgress(cardinalNumber = 6, year = 2024, semester = 2) - val newCardinalBeforeSave = - CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - val newCardinalAfterSave = - CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - - every { cardinalGetService.validateCardinal(7) } just Runs - every { cardinalGetService.findInProgress() } returns listOf(oldCardinal) - every { cardinalMapper.from(request) } returns newCardinalBeforeSave - every { cardinalSaveService.save(newCardinalBeforeSave) } returns newCardinalAfterSave - - useCase.save(request) - - verify { cardinalGetService.findInProgress() } - verify { cardinalSaveService.save(newCardinalBeforeSave) } - - oldCardinal.status shouldBe CardinalStatus.DONE - newCardinalAfterSave.status shouldBe CardinalStatus.IN_PROGRESS - } - } - } - - describe("update") { - it("연도와 학기를 변경한다") { - val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2024, semester = 2) - val dto = CardinalUpdateRequest(1L, 2025, 1, false) - - cardinal.update(dto) - - cardinal.year shouldBe 2025 - cardinal.semester shouldBe 1 - } - } - - describe("findAll") { - it("조회된 모든 기수를 DTO로 매핑한다") { - val cardinal1 = - CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 6, year = 2024, semester = 2) - val cardinal2 = - CardinalTestFixture.createCardinalInProgress(id = 2L, cardinalNumber = 7, year = 2025, semester = 1) - val cardinals = listOf(cardinal1, cardinal2) - val now = LocalDateTime.now() - - val response1 = CardinalResponse(1L, 6, 2024, 2, CardinalStatus.DONE, now.minusDays(5), now.minusDays(3)) - val response2 = - CardinalResponse(2L, 7, 2025, 1, CardinalStatus.IN_PROGRESS, now.minusDays(2), now) - - every { cardinalGetService.findAll() } returns cardinals - every { cardinalMapper.to(cardinal1) } returns response1 - every { cardinalMapper.to(cardinal2) } returns response2 - - val responses = useCase.findAll() - - verify { cardinalGetService.findAll() } - verify(exactly = 2) { cardinalMapper.to(any()) } - - responses shouldHaveSize 2 - responses.map { it.cardinalNumber() } shouldBe listOf(6, 7) - responses.map { it.status() } shouldBe listOf(CardinalStatus.DONE, CardinalStatus.IN_PROGRESS) - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt deleted file mode 100644 index d2164df9..00000000 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt +++ /dev/null @@ -1,238 +0,0 @@ -package com.weeth.domain.user.application.usecase - -import com.weeth.domain.attendance.domain.service.AttendanceSaveService -import com.weeth.domain.schedule.domain.entity.Meeting -import com.weeth.domain.schedule.domain.service.MeetingGetService -import com.weeth.domain.user.application.dto.request.UserRequestDto -import com.weeth.domain.user.application.dto.response.UserResponseDto -import com.weeth.domain.user.application.exception.InvalidUserOrderException -import com.weeth.domain.user.application.mapper.UserMapper -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status -import com.weeth.domain.user.domain.entity.enums.UsersOrderBy -import com.weeth.domain.user.domain.service.CardinalGetService -import com.weeth.domain.user.domain.service.UserCardinalGetService -import com.weeth.domain.user.domain.service.UserCardinalSaveService -import com.weeth.domain.user.domain.service.UserDeleteService -import com.weeth.domain.user.domain.service.UserGetService -import com.weeth.domain.user.domain.service.UserUpdateService -import com.weeth.domain.user.fixture.CardinalTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import com.weeth.global.auth.jwt.service.JwtRedisService -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.springframework.security.crypto.password.PasswordEncoder -import java.time.LocalDateTime -import java.util.ArrayList - -class UserManageUseCaseTest : - DescribeSpec({ - - val userGetService = mockk() - val userUpdateService = mockk(relaxUnitFun = true) - val userDeleteService = mockk(relaxUnitFun = true) - val attendanceSaveService = mockk(relaxUnitFun = true) - val meetingGetService = mockk() - val jwtRedisService = mockk(relaxUnitFun = true) - val cardinalGetService = mockk() - val userCardinalSaveService = mockk(relaxUnitFun = true) - val userCardinalGetService = mockk() - val userMapper = mockk() - val passwordEncoder = mockk() - - val useCase = - UserManageUseCaseImpl( - userGetService, - userUpdateService, - userDeleteService, - attendanceSaveService, - meetingGetService, - jwtRedisService, - cardinalGetService, - userCardinalSaveService, - userCardinalGetService, - userMapper, - passwordEncoder, - ) - - describe("findAllByAdmin") { - context("orderBy가 null이면") { - it("예외가 발생한다") { - shouldThrow { - useCase.findAllByAdmin(null) - } - } - } - - context("orderBy에 맞게 정렬하여 조회할 때") { - it("정렬된 결과를 반환한다") { - val user1 = UserTestFixture.createActiveUser1() - val user2 = UserTestFixture.createWaitingUser2() - val cd1 = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 6, year = 2020, semester = 2) - val cd2 = CardinalTestFixture.createCardinal(id = 2L, cardinalNumber = 7, year = 2021, semester = 1) - val uc1 = UserCardinal(user1, cd1) - val uc2 = UserCardinal(user2, cd2) - - val adminResponse1 = - UserResponseDto.AdminResponse( - 1, - "aaa", - "a@a.com", - "202034420", - "01011112222", - "산업공학과", - listOf(6), - null, - Status.ACTIVE, - null, - 0, - 0, - 0, - 0, - 0, - LocalDateTime.now().minusDays(3), - LocalDateTime.now(), - ) - val adminResponse2 = - UserResponseDto.AdminResponse( - 2, - "bbb", - "b@b.com", - "202045678", - "01033334444", - "컴퓨터공학과", - listOf(7), - null, - Status.WAITING, - null, - 0, - 0, - 0, - 0, - 0, - LocalDateTime.now().minusDays(2), - LocalDateTime.now(), - ) - - every { userCardinalGetService.getUserCardinals(user1) } returns listOf(uc1) - every { userCardinalGetService.getUserCardinals(user2) } returns listOf(uc2) - every { userCardinalGetService.findAll() } returns listOf(uc2, uc1) - every { userMapper.toAdminResponse(user1, listOf(uc1)) } returns adminResponse1 - every { userMapper.toAdminResponse(user2, listOf(uc2)) } returns adminResponse2 - - val result = useCase.findAllByAdmin(UsersOrderBy.NAME_ASCENDING) - - result shouldHaveSize 2 - result[0].name() shouldBe "aaa" - result[1].name() shouldBe "bbb" - } - } - } - - describe("accept") { - it("비활성유저 승인시 출석초기화가 정상 호출된다") { - val user1 = UserTestFixture.createWaitingUser1(1L) - val userIds = UserRequestDto.UserId(listOf(1L)) - val cardinal = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 8, year = 2020, semester = 2) - val meetings = listOf(mockk()) - - every { userGetService.findAll(userIds.userId()) } returns listOf(user1) - every { userCardinalGetService.getCurrentCardinal(user1) } returns cardinal - every { meetingGetService.find(8) } returns meetings - - useCase.accept(userIds) - - verify { userUpdateService.accept(user1) } - verify { attendanceSaveService.init(user1, meetings) } - } - } - - describe("update") { - it("유저권한변경시 DB와 Redis 모두 갱신된다") { - val user1 = UserTestFixture.createActiveUser1(1L) - val request = UserRequestDto.UserRoleUpdate(1L, Role.ADMIN) - - every { userGetService.find(1L) } returns user1 - - useCase.update(listOf(request)) - - verify { userUpdateService.update(user1, "ADMIN") } - verify { jwtRedisService.updateRole(1L, "ADMIN") } - } - } - - describe("leave") { - it("회원탈퇴시 토큰무효화 및 유저상태변경된다") { - val user1 = UserTestFixture.createActiveUser1(1L) - every { userGetService.find(1L) } returns user1 - - useCase.leave(1L) - - verify { jwtRedisService.delete(1L) } - verify { userDeleteService.leave(user1) } - } - } - - describe("ban") { - it("회원ban시 토큰무효화 및 유저상태변경된다") { - val user1 = UserTestFixture.createActiveUser1(1L) - val ids = UserRequestDto.UserId(listOf(1L)) - every { userGetService.findAll(ids.userId()) } returns listOf(user1) - - useCase.ban(ids) - - verify { jwtRedisService.delete(1L) } - verify { userDeleteService.ban(user1) } - } - } - - describe("applyOB") { - it("현재기수 OB신청시 출석초기화 및 기수업데이트된다") { - val user = - User - .builder() - .id(1L) - .name("aaa") - .status(Status.ACTIVE) - .attendances(ArrayList()) - .build() - val nextCardinal = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 4, year = 2020, semester = 2) - val request = UserRequestDto.UserApplyOB(1L, 4) - val meeting = listOf(mockk()) - - every { userGetService.find(1L) } returns user - every { cardinalGetService.findByAdminSide(4) } returns nextCardinal - every { userCardinalGetService.notContains(user, nextCardinal) } returns true - every { userCardinalGetService.isCurrent(user, nextCardinal) } returns true - every { meetingGetService.find(4) } returns meeting - - useCase.applyOB(listOf(request)) - - verify { attendanceSaveService.init(user, meeting) } - verify { userCardinalSaveService.save(any()) } - } - } - - describe("reset") { - it("비밀번호초기화시 모든유저에 reset이 호출된다") { - val user1 = UserTestFixture.createActiveUser1(1L) - val user2 = UserTestFixture.createActiveUser2(2L) - val ids = UserRequestDto.UserId(listOf(1L, 2L)) - - every { userGetService.findAll(ids.userId()) } returns listOf(user1, user2) - - useCase.reset(ids) - - verify { userGetService.findAll(ids.userId()) } - verify { userUpdateService.reset(user1, passwordEncoder) } - verify { userUpdateService.reset(user2, passwordEncoder) } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCaseTest.kt new file mode 100644 index 00000000..4a45a679 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCaseTest.kt @@ -0,0 +1,63 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.AgreeTermsRequest +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.fixture.UserTestFixture +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.jwt.domain.enums.TokenType +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class AgreeTermsUseCaseTest : + DescribeSpec({ + val userRepository = mockk() + val jwtManageUseCase = mockk() + val useCase = AgreeTermsUseCase(userRepository, jwtManageUseCase) + + beforeTest { clearMocks(userRepository, jwtManageUseCase) } + + describe("execute") { + context("모든 약관에 동의한 경우") { + it("약관 동의 후 ACCESS 토큰을 발급한다") { + val user = UserTestFixture.createWaitingUser1(1L) + every { userRepository.getById(1L) } returns user + every { jwtManageUseCase.create(1L, user.emailValue, TokenType.ACCESS) } returns + JwtDto("access", "refresh") + + val result = useCase.execute(1L, AgreeTermsRequest(termsAgreed = true, privacyAgreed = true)) + + user.termsAgreed shouldBe true + user.privacyAgreed shouldBe true + result.accessToken shouldBe "access" + result.refreshToken shouldBe "refresh" + verify(exactly = 1) { jwtManageUseCase.create(1L, user.emailValue, TokenType.ACCESS) } + } + } + + context("약관에 동의하지 않은 경우") { + it("termsAgreed가 false이면 예외가 발생한다") { + val user = UserTestFixture.createActiveUser1(1L) + every { userRepository.getById(1L) } returns user + + shouldThrow { + useCase.execute(1L, AgreeTermsRequest(termsAgreed = false, privacyAgreed = true)) + } + } + + it("privacyAgreed가 false이면 예외가 발생한다") { + val user = UserTestFixture.createActiveUser1(1L) + every { userRepository.getById(1L) } returns user + + shouldThrow { + useCase.execute(1L, AgreeTermsRequest(termsAgreed = true, privacyAgreed = false)) + } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt new file mode 100644 index 00000000..ca822e46 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt @@ -0,0 +1,51 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import jakarta.servlet.http.HttpServletRequest + +class AuthUserUseCaseTest : + DescribeSpec({ + val userReader = mockk() + val jwtManageUseCase = mockk() + val jwtTokenExtractor = mockk() + + val useCase = + AuthUserUseCase( + userReader, + jwtManageUseCase, + jwtTokenExtractor, + ) + + describe("leave") { + it("회원 탈퇴 시 상태를 LEFT로 변경한다") { + val user = UserTestFixture.createActiveUser1(1L) + every { userReader.getById(1L) } returns user + + useCase.leave(1L) + + user.status shouldBe Status.LEFT + } + } + + describe("refreshToken") { + it("요청에서 refresh token을 추출해 재발급한다") { + val servletRequest = mockk() + every { jwtTokenExtractor.extractRefreshToken(servletRequest) } returns "refresh-token" + every { jwtManageUseCase.reIssueToken("refresh-token") } returns JwtDto("new-access", "new-refresh") + + val result = useCase.refreshToken(servletRequest) + + result.accessToken shouldBe "new-access" + result.refreshToken shouldBe "new-refresh" + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt new file mode 100644 index 00000000..14a4bac5 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt @@ -0,0 +1,184 @@ +package com.weeth.domain.user.application.usecase.command + +import com.fasterxml.jackson.databind.ObjectMapper +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.user.application.dto.request.SocialLoginRequest +import com.weeth.domain.user.application.exception.EmailNotFoundException +import com.weeth.domain.user.application.mapper.UserMapper +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.UserSocialAccount +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.port.SocialAuthPort +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.repository.UserSocialAccountRepository +import com.weeth.domain.user.domain.vo.SocialAuthResult +import com.weeth.domain.user.fixture.UserTestFixture +import com.weeth.domain.user.infrastructure.SocialAuthPortRegistry +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.jwt.domain.enums.TokenType +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.util.Optional + +class SocialLoginUseCaseTest : + DescribeSpec({ + val userRepository = mockk() + val userSocialAccountRepository = mockk() + val socialAuthPortRegistry = mockk() + val socialAuthPort = mockk() + val jwtManageUseCase = mockk() + val fileAccessUrlPort = mockk() + val userMapper = UserMapper(fileAccessUrlPort) + val objectMapper = mockk() + + val useCase = + SocialLoginUseCase( + userRepository = userRepository, + userSocialAccountRepository = userSocialAccountRepository, + socialAuthPortRegistry = socialAuthPortRegistry, + jwtManageUseCase = jwtManageUseCase, + userMapper = userMapper, + objectMapper, + ) + + beforeTest { + clearMocks( + userRepository, + userSocialAccountRepository, + socialAuthPortRegistry, + socialAuthPort, + jwtManageUseCase, + ) + } + + describe("socialLoginByApple") { + it("약관 동의 완료된 기존 유저는 ACCESS 토큰과 registered=true를 반환한다") { + val request = SocialLoginRequest(authCode = "apple-auth-code") + val user = UserTestFixture.createRegisteredUser(1L) + val account = + UserSocialAccount( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-1", + user = user, + ) + val authResult = + SocialAuthResult( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-1", + email = "", + emailVerified = false, + name = null, + ) + + every { socialAuthPortRegistry.get(SocialProvider.APPLE) } returns socialAuthPort + every { socialAuthPort.authenticate("apple-auth-code") } returns authResult + every { + userSocialAccountRepository.findByProviderAndProviderUserId(SocialProvider.APPLE, "apple-user-1") + } returns Optional.of(account) + every { jwtManageUseCase.create(user.id, user.emailValue, TokenType.ACCESS) } returns + JwtDto("access", "refresh") + + val result = useCase.socialLoginByApple(request) + + result.accessToken shouldBe "access" + result.refreshToken shouldBe "refresh" + result.registered shouldBe true + + verify(exactly = 0) { userRepository.save(any()) } + verify(exactly = 0) { userSocialAccountRepository.save(any()) } + } + + it("약관 미동의 기존 유저는 TEMPORARY 토큰과 registered=false를 반환한다") { + val request = SocialLoginRequest(authCode = "apple-auth-code") + val user = UserTestFixture.createActiveUser1(1L) // ACTIVE이지만 약관 미동의 + val account = + UserSocialAccount( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-1", + user = user, + ) + val authResult = + SocialAuthResult( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-1", + email = "", + emailVerified = false, + name = null, + ) + + every { socialAuthPortRegistry.get(SocialProvider.APPLE) } returns socialAuthPort + every { socialAuthPort.authenticate("apple-auth-code") } returns authResult + every { + userSocialAccountRepository.findByProviderAndProviderUserId(SocialProvider.APPLE, "apple-user-1") + } returns Optional.of(account) + every { jwtManageUseCase.create(user.id, user.emailValue, TokenType.TEMPORARY) } returns + JwtDto("temp-access", "refresh") + + val result = useCase.socialLoginByApple(request) + + result.accessToken shouldBe "temp-access" + result.registered shouldBe false + } + + it("신규 유저는 TEMPORARY 토큰과 registered=false를 반환한다") { + val request = SocialLoginRequest(authCode = "apple-auth-code") + val authResult = + SocialAuthResult( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-new", + email = "new@test.com", + emailVerified = true, + name = "신규유저", + ) + + every { socialAuthPortRegistry.get(SocialProvider.APPLE) } returns socialAuthPort + every { socialAuthPort.authenticate("apple-auth-code") } returns authResult + every { + userSocialAccountRepository.findByProviderAndProviderUserId(SocialProvider.APPLE, "apple-user-new") + } returns Optional.empty() + every { userRepository.save(any()) } answers { + val saved = firstArg() + saved + } + every { userSocialAccountRepository.save(any()) } answers { firstArg() } + every { jwtManageUseCase.create(any(), any(), TokenType.TEMPORARY) } returns + JwtDto("temp-access", "refresh") + + val result = useCase.socialLoginByApple(request) + + result.registered shouldBe false + verify(exactly = 1) { jwtManageUseCase.create(any(), any(), TokenType.TEMPORARY) } + } + + it("신규 연동 계정은 이메일이 없으면 예외가 발생한다") { + val request = SocialLoginRequest(authCode = "apple-auth-code") + val authResult = + SocialAuthResult( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-2", + email = "", + emailVerified = false, + name = null, + ) + + every { socialAuthPortRegistry.get(SocialProvider.APPLE) } returns socialAuthPort + every { socialAuthPort.authenticate("apple-auth-code") } returns authResult + every { + userSocialAccountRepository.findByProviderAndProviderUserId(SocialProvider.APPLE, "apple-user-2") + } returns Optional.empty() + + shouldThrow { + useCase.socialLoginByApple(request) + } + + verify(exactly = 0) { userRepository.save(any()) } + verify(exactly = 0) { userSocialAccountRepository.save(any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt new file mode 100644 index 00000000..1f6a84de --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -0,0 +1,159 @@ +package com.weeth.domain.user.domain.entity + +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.vo.Email +import com.weeth.global.common.vo.PhoneNumber +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe + +class UserTest : + StringSpec({ + "accept/ban/leave 상태 전환" { + val user = User(name = "test", email = Email.from("test@test.com"), studentId = "20200001") + + user.accept() + user.status shouldBe Status.ACTIVE + + user.ban() + user.status shouldBe Status.BANNED + + user.leave() + user.status shouldBe Status.LEFT + } + + "User.create 기본 status는 WAITING이다" { + val user = User.create(name = "test", email = "test@test.com") + + user.status shouldBe Status.WAITING + } + + "User.create에 status를 명시하면 해당 상태로 생성된다" { + val user = User.create(name = "test", email = "test@test.com", status = Status.ACTIVE) + + user.status shouldBe Status.ACTIVE + } + + "생성 시 빈 이름은 예외가 발생한다" { + shouldThrow { + User(name = " ", email = Email.from("test@test.com")) + } + } + + "update에서 빈 이름은 예외가 발생한다" { + val user = User(name = "test", email = Email.from("test@test.com")) + + shouldThrow { + user.update( + name = "", + email = Email.from("test@test.com"), + studentId = "123", + tel = PhoneNumber.from("01012345678"), + school = "가천대학교", + department = "CS", + ) + } + } + + "프로필 미완성 판정 — 기본 생성 시 false" { + val user = User.create(name = "test", email = "test@test.com") + + user.isProfileCompleted() shouldBe false + } + + "프로필 완성 판정 — 모든 필드 채워졌을 때 true" { + val user = + User.create( + name = "test", + email = "test@test.com", + studentId = "20200001", + tel = "01012345678", + school = "가천대학교", + department = "CS", + ) + + user.isProfileCompleted() shouldBe true + } + + "missingProfileFields — 기본 생성 시 비어있는 필드 목록 반환" { + val user = User.create(name = "test", email = "test@test.com") + + user.missingProfileFields() shouldContainExactlyInAnyOrder + listOf("studentId", "tel", "school", "department") + } + + "missingProfileFields — 모든 필드 채워졌을 때 빈 리스트 반환" { + val user = + User.create( + name = "test", + email = "test@test.com", + studentId = "20200001", + tel = "01012345678", + school = "가천대학교", + department = "CS", + ) + + user.missingProfileFields().shouldBeEmpty() + } + + "missingProfileFields — 일부 필드만 비어있을 때 해당 필드만 반환" { + val user = + User.create( + name = "test", + email = "test@test.com", + studentId = "20200001", + tel = "01012345678", + ) + + user.missingProfileFields() shouldContainExactlyInAnyOrder listOf("school", "department") + } + + "isActive / isInactive 동작" { + val user = User(name = "test", email = Email.from("test@test.com")) + user.isActive() shouldBe false + user.isInactive() shouldBe true + + user.accept() + user.isActive() shouldBe true + user.isInactive() shouldBe false + } + + "isBannedOrLeft 동작" { + val user = User(name = "test", email = Email.from("test@test.com")) + user.isBannedOrLeft() shouldBe false + + user.ban() + user.isBannedOrLeft() shouldBe true + + user.accept() + user.leave() + user.isBannedOrLeft() shouldBe true + } + + "agreeTerms 성공 — 모두 true" { + val user = User(name = "test", email = Email.from("test@test.com")) + + user.agreeTerms(termsAgreed = true, privacyAgreed = true) + + user.termsAgreed shouldBe true + user.privacyAgreed shouldBe true + } + + "agreeTerms 실패 — termsAgreed가 false" { + val user = User(name = "test", email = Email.from("test@test.com")) + + shouldThrow { + user.agreeTerms(termsAgreed = false, privacyAgreed = true) + } + } + + "agreeTerms 실패 — privacyAgreed가 false" { + val user = User(name = "test", email = Email.from("test@test.com")) + + shouldThrow { + user.agreeTerms(termsAgreed = true, privacyAgreed = false) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/CardinalRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/repository/CardinalRepositoryTest.kt deleted file mode 100644 index decac4e6..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/CardinalRepositoryTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.weeth.domain.user.domain.repository - -import com.weeth.config.TestContainersConfig -import com.weeth.domain.user.fixture.CardinalTestFixture -import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.optional.shouldBePresent -import io.kotest.matchers.shouldBe -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.context.annotation.Import - -@DataJpaTest -@Import(TestContainersConfig::class) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class CardinalRepositoryTest( - private val cardinalRepository: CardinalRepository, -) : StringSpec({ - - "기수번호로 조회된다" { - val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - cardinalRepository.save(cardinal) - - val result = cardinalRepository.findByCardinalNumber(7) - - result.shouldBePresent { - it.year shouldBe 2025 - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt deleted file mode 100644 index 7060659f..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.weeth.domain.user.domain.repository - -import com.weeth.config.TestContainersConfig -import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.fixture.CardinalTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.context.annotation.Import - -@DataJpaTest -@Import(TestContainersConfig::class) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class UserCardinalRepositoryTest( - private val userRepository: UserRepository, - private val cardinalRepository: CardinalRepository, - private val userCardinalRepository: UserCardinalRepository, -) : DescribeSpec({ - - describe("findAllByUserOrderByCardinalCardinalNumberDesc") { - it("유저별 기수가 내림차순으로 조회된다") { - val user = UserTestFixture.createActiveUser1() - userRepository.save(user) - - val cardinal1 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 5, year = 2023, semester = 1)) - val cardinal2 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2023, semester = 2)) - val cardinal3 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2024, semester = 1)) - - userCardinalRepository.saveAll( - listOf( - UserCardinal(user, cardinal1), - UserCardinal(user, cardinal2), - UserCardinal(user, cardinal3), - ), - ) - - val result = userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user) - - result shouldHaveSize 3 - result[0].cardinal.cardinalNumber shouldBe 7 - result[1].cardinal.cardinalNumber shouldBe 6 - result[2].cardinal.cardinalNumber shouldBe 5 - } - } - - describe("findAllByUsers") { - it("여러 유저의 기수를 유저별 내림차순으로 조회한다") { - val user1 = UserTestFixture.createActiveUser1() - val user2 = UserTestFixture.createActiveUser2() - userRepository.save(user1) - userRepository.save(user2) - - val c1 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 5, year = 2023, semester = 1)) - val c2 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2023, semester = 2)) - val c3 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2024, semester = 1)) - val c4 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2024, semester = 2)) - - userCardinalRepository.saveAll( - listOf( - UserCardinal(user1, c3), - UserCardinal(user1, c2), - ), - ) - userCardinalRepository.saveAll( - listOf( - UserCardinal(user2, c4), - UserCardinal(user2, c1), - ), - ) - - val result = userCardinalRepository.findAllByUsers(listOf(user1, user2)) - - result shouldHaveSize 4 - result[0].user.id shouldBe user1.id - result[0].cardinal.cardinalNumber shouldBe 7 - result[1].cardinal.cardinalNumber shouldBe 6 - - result[2].user.id shouldBe user2.id - result[2].cardinal.cardinalNumber shouldBe 8 - result[3].cardinal.cardinalNumber shouldBe 5 - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt deleted file mode 100644 index 5b50656c..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.weeth.domain.user.domain.repository - -import com.weeth.config.TestContainersConfig -import com.weeth.domain.user.domain.entity.enums.Status -import com.weeth.domain.user.fixture.CardinalTestFixture -import com.weeth.domain.user.fixture.UserCardinalTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.collections.shouldHaveSize -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.context.annotation.Import -import org.springframework.data.domain.PageRequest - -@DataJpaTest -@Import(TestContainersConfig::class) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class UserRepositoryTest( - private val userRepository: UserRepository, - private val userCardinalRepository: UserCardinalRepository, - private val cardinalRepository: CardinalRepository, -) : DescribeSpec({ - - lateinit var cardinal7: com.weeth.domain.user.domain.entity.Cardinal - lateinit var cardinal8: com.weeth.domain.user.domain.entity.Cardinal - - beforeEach { - cardinal7 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2026, semester = 1)) - cardinal8 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2026, semester = 2)) - - val user1 = userRepository.save(UserTestFixture.createActiveUser1()) - val user2 = userRepository.save(UserTestFixture.createActiveUser2()) - val user3 = userRepository.save(UserTestFixture.createWaitingUser1()) - - user1.accept() - user2.accept() - userCardinalRepository.flush() - - userCardinalRepository.save(UserCardinalTestFixture.linkUserCardinal(user1, cardinal7)) - userCardinalRepository.save(UserCardinalTestFixture.linkUserCardinal(user2, cardinal8)) - userCardinalRepository.save(UserCardinalTestFixture.linkUserCardinal(user3, cardinal7)) - } - - describe("findAllByCardinalAndStatus") { - it("특정 기수 + 상태에 맞는 유저만 조회된다") { - val result = userRepository.findAllByCardinalAndStatus(cardinal7, Status.ACTIVE) - - result shouldHaveSize 1 - result.map { it.name } shouldContainExactly listOf("적순") - } - } - - describe("findAllByStatusOrderedByCardinalAndName") { - it("상태별로 최신 기수순 + 이름 오름차순으로 정렬된다") { - val pageable = PageRequest.of(0, 10) - - val resultSlice = userRepository.findAllByStatusOrderedByCardinalAndName(Status.ACTIVE, pageable) - val result = resultSlice.content - - result shouldHaveSize 2 - result.map { it.name } shouldContainExactly listOf("적순2", "적순") - } - } - - describe("findAllByCardinalOrderByNameAsc") { - it("Active인 유저들 중 특정 기수 + 이름 오름차순으로 정렬한다") { - val pageable = PageRequest.of(0, 10) - - val resultSlice = userRepository.findAllByCardinalOrderByNameAsc(Status.ACTIVE, cardinal7, pageable) - val result = resultSlice.content - - result shouldHaveSize 1 - result.map { it.name } shouldContainExactly listOf("적순") - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/service/CardinalGetServiceTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/service/CardinalGetServiceTest.kt deleted file mode 100644 index b900c9a2..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/service/CardinalGetServiceTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.weeth.domain.user.domain.service - -import com.weeth.domain.user.application.exception.DuplicateCardinalException -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.repository.CardinalRepository -import io.kotest.assertions.throwables.shouldNotThrowAny -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import java.util.Optional - -class CardinalGetServiceTest : - DescribeSpec({ - - val cardinalRepository = mockk() - val cardinalGetService = CardinalGetService(cardinalRepository) - - describe("findByAdminSide") { - context("존재하지 않는 기수를 넣었을 때") { - it("새로 저장된다") { - every { cardinalRepository.findByCardinalNumber(7) } returns Optional.empty() - every { cardinalRepository.save(any()) } returns - Cardinal.builder().cardinalNumber(7).build() - - val result = cardinalGetService.findByAdminSide(7) - - result.cardinalNumber shouldBe 7 - } - } - } - - describe("validateCardinal") { - context("중복된 기수일 때") { - it("예외를 던진다") { - every { cardinalRepository.findByCardinalNumber(7) } returns - Optional.of(Cardinal.builder().cardinalNumber(7).build()) - - shouldThrow { - cardinalGetService.validateCardinal(7) - } - } - } - - context("중복되지 않는 기수일 때") { - it("예외를 던지지 않는다") { - every { cardinalRepository.findByCardinalNumber(7) } returns Optional.empty() - - shouldNotThrowAny { - cardinalGetService.validateCardinal(7) - } - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalGetServiceTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalGetServiceTest.kt deleted file mode 100644 index b784670d..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalGetServiceTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.weeth.domain.user.domain.service - -import com.weeth.domain.user.application.exception.CardinalNotFoundException -import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.repository.UserCardinalRepository -import com.weeth.domain.user.fixture.CardinalTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.mockk.every -import io.mockk.mockk - -class UserCardinalGetServiceTest : - DescribeSpec({ - - val userCardinalRepository = mockk() - val userCardinalGetService = UserCardinalGetService(userCardinalRepository) - - describe("notContains") { - it("유저의 기수 목록 중 특정 기수가 없으면 true를 반환한다") { - val user = UserTestFixture.createActiveUser1() - val existingCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 2) - val targetCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2026, semester = 1) - val userCardinal = UserCardinal(user, existingCardinal) - - every { - userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user) - } returns listOf(userCardinal) - - val result = userCardinalGetService.notContains(user, targetCardinal) - - result.shouldBeTrue() - } - } - - describe("isCurrent") { - context("현재 유저의 최신 기수보다 최신 기수일 때") { - it("true를 반환한다") { - val user = UserTestFixture.createActiveUser1() - val oldCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 2) - val newCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2026, semester = 1) - val userCardinal = UserCardinal(user, oldCardinal) - - every { - userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user) - } returns listOf(userCardinal) - - val result = userCardinalGetService.isCurrent(user, newCardinal) - - result.shouldBeTrue() - } - } - - context("새 기수가 기존 최대보다 작을 때") { - it("false를 반환한다") { - val user = UserTestFixture.createActiveUser1() - val oldCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - val newCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2024, semester = 2) - val userCardinal = UserCardinal(user, oldCardinal) - - every { - userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user) - } returns listOf(userCardinal) - - val result = userCardinalGetService.isCurrent(user, newCardinal) - - result.shouldBeFalse() - } - } - - context("유저가 어떤 기수도 가지고 있지 않을 때") { - it("CardinalNotFoundException이 발생한다") { - val user = UserTestFixture.createActiveUser1() - val newCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2026, semester = 1) - - every { - userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user) - } returns listOf() - - shouldThrow { - userCardinalGetService.isCurrent(user, newCardinal) - } - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/service/UserGetServiceTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/service/UserGetServiceTest.kt deleted file mode 100644 index 7b8cc38d..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/service/UserGetServiceTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.weeth.domain.user.domain.service - -import com.weeth.domain.user.application.exception.UserNotFoundException -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.repository.UserRepository -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.mockk.every -import io.mockk.mockk -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.SliceImpl -import java.util.Optional - -class UserGetServiceTest : - DescribeSpec({ - - val userRepository = mockk() - val userGetService = UserGetService(userRepository) - - describe("find(Long)") { - context("존재하지 않는 유저일 때") { - it("예외를 던진다") { - val userId = 1L - every { userRepository.findById(userId) } returns Optional.empty() - - shouldThrow { - userGetService.find(userId) - } - } - } - } - - describe("find(String)") { - context("존재하지 않는 유저일 때") { - it("예외를 던진다") { - val email = "test@test.com" - every { userRepository.findByEmail(email) } returns Optional.empty() - - shouldThrow { - userGetService.find(email) - } - } - } - } - - describe("findAll(Pageable)") { - context("빈 슬라이스 반환 시") { - it("유저 예외를 던진다") { - val pageable = PageRequest.of(0, 10) - val emptySlice = SliceImpl(listOf(), pageable, false) - - every { - userRepository.findAllByStatusOrderedByCardinalAndName(any(), eq(pageable)) - } returns emptySlice - - shouldThrow { - userGetService.findAll(pageable) - } - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt deleted file mode 100644 index 8097dfa8..00000000 --- a/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.weeth.domain.user.fixture - -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.enums.CardinalStatus - -object CardinalTestFixture { - fun createCardinal( - id: Long? = null, - cardinalNumber: Int, - year: Int, - semester: Int, - ): Cardinal = - Cardinal - .builder() - .id(id) - .cardinalNumber(cardinalNumber) - .year(year) - .semester(semester) - .status(CardinalStatus.DONE) - .build() - - fun createCardinalInProgress( - id: Long? = null, - cardinalNumber: Int, - year: Int, - semester: Int, - ): Cardinal = - Cardinal - .builder() - .id(id) - .cardinalNumber(cardinalNumber) - .year(year) - .semester(semester) - .status(CardinalStatus.IN_PROGRESS) - .build() -} diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/SessionTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/SessionTestFixture.kt new file mode 100644 index 00000000..1471d9f2 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/fixture/SessionTestFixture.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.user.fixture + +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.session.domain.entity.Session +import java.time.LocalDateTime + +object SessionTestFixture { + fun createSession( + cardinalNumber: Int, + title: String = "테스트 세션", + start: LocalDateTime = LocalDateTime.of(2025, 3, 1, 14, 0), + end: LocalDateTime = LocalDateTime.of(2025, 3, 1, 16, 0), + code: Int = 1234, + ): Session = + Session( + club = ClubTestFixture.createClub(), + title = title, + cardinal = cardinalNumber, + start = start, + end = end, + code = code, + ) +} diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/UserCardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/UserCardinalTestFixture.kt deleted file mode 100644 index 2636c8f9..00000000 --- a/src/test/kotlin/com/weeth/domain/user/fixture/UserCardinalTestFixture.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.domain.user.fixture - -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal - -object UserCardinalTestFixture { - fun linkUserCardinal( - user: User, - cardinal: Cardinal, - ): UserCardinal = UserCardinal(user, cardinal) -} diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt index a28777c4..7445c9cb 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt @@ -1,53 +1,57 @@ package com.weeth.domain.user.fixture import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.vo.Email +import org.springframework.test.util.ReflectionTestUtils object UserTestFixture { - fun createActiveUser1(id: Long? = null): User = - User - .builder() - .id(id) - .name("적순") - .email("test1@test.com") - .status(Status.ACTIVE) - .build() - - fun createActiveUser2(id: Long? = null): User = - User - .builder() - .id(id) - .name("적순2") - .email("test2@test.com") - .status(Status.ACTIVE) - .build() - - fun createWaitingUser1(id: Long? = null): User = - User - .builder() - .id(id) - .name("순적") - .email("test2@test.com") - .status(Status.WAITING) - .build() - - fun createWaitingUser2(id: Long? = null): User = - User - .builder() - .id(id) - .name("순적2") - .email("test3@test.com") - .status(Status.WAITING) - .build() - - fun createAdmin(id: Long? = null): User = - User - .builder() - .id(id) - .name("적순") - .email("admin@test.com") - .status(Status.ACTIVE) - .role(Role.ADMIN) - .build() + fun createActiveUser1(id: Long = 0L): User = + User( + name = "적순", + email = Email.from("test1@test.com"), + status = Status.ACTIVE, + ).applyId(id) + + fun createActiveUser2(id: Long = 0L): User = + User( + name = "적순2", + email = Email.from("test2@test.com"), + status = Status.ACTIVE, + ).applyId(id) + + fun createWaitingUser1(id: Long = 0L): User = + User( + name = "순적", + email = Email.from("test2@test.com"), + status = Status.WAITING, + ).applyId(id) + + fun createWaitingUser2(id: Long = 0L): User = + User( + name = "순적2", + email = Email.from("test3@test.com"), + status = Status.WAITING, + ).applyId(id) + + fun createRegisteredUser(id: Long = 0L): User = + User( + name = "등록완료", + email = Email.from("registered@test.com"), + status = Status.ACTIVE, + ).apply { + agreeTerms(termsAgreed = true, privacyAgreed = true) + }.applyId(id) + + fun createAdmin(id: Long = 0L): User = + User( + name = "적순", + email = Email.from("admin@test.com"), + status = Status.ACTIVE, + ).applyId(id) + + private fun User.applyId(id: Long): User = + apply { + if (id != 0L) ReflectionTestUtils.setField(this, "id", id) + } } diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt new file mode 100644 index 00000000..5722d1a3 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt @@ -0,0 +1,131 @@ +package com.weeth.global.auth.jwt.application.service + +import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException +import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.config.properties.CookieProperties +import com.weeth.global.config.properties.JwtProperties +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletRequest + +class JwtTokenExtractorTest : + DescribeSpec({ + val jwtProperties = + JwtProperties( + key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + access = JwtProperties.TokenProperties(expiration = 60_000L, header = "Auth"), + refresh = JwtProperties.TokenProperties(expiration = 120_000L, header = "Refresh"), + ) + + val cookieProperties = CookieProperties(accessTokenName = "access_token", refreshTokenName = "refresh_token") + val jwtProvider = mockk() + val jwtTokenExtractor = JwtTokenExtractor(jwtProperties, jwtProvider, cookieProperties) + + beforeTest { + clearMocks(jwtProvider) + } + + describe("extractAccessToken") { + it("Bearer 헤더에서 access token을 추출한다") { + val request = mockk() + every { request.getHeader("Auth") } returns "Bearer access-token" + + val token = jwtTokenExtractor.extractAccessToken(request) + + token shouldBe "access-token" + } + } + + describe("extractRefreshToken") { + it("Cookie에서 refresh token을 우선 추출한다") { + val request = mockk() + every { request.cookies } returns arrayOf(Cookie("refresh_token", "cookie-refresh-token")) + + val token = jwtTokenExtractor.extractRefreshToken(request) + + token shouldBe "cookie-refresh-token" + } + + it("Cookie가 없으면 Header에서 refresh token을 추출한다") { + val request = mockk() + every { request.cookies } returns null + every { request.getHeader("Refresh") } returns "Bearer header-refresh-token" + + val token = jwtTokenExtractor.extractRefreshToken(request) + + token shouldBe "header-refresh-token" + } + + it("Cookie가 빈 값이면 Header에서 refresh token을 추출한다") { + val request = mockk() + every { request.cookies } returns arrayOf(Cookie("refresh_token", "")) + every { request.getHeader("Refresh") } returns "Bearer header-refresh-token" + + val token = jwtTokenExtractor.extractRefreshToken(request) + + token shouldBe "header-refresh-token" + } + + it("Cookie와 Header 둘 다 없으면 TokenNotFoundException이 발생한다") { + val request = mockk() + every { request.cookies } returns null + every { request.getHeader("Refresh") } returns null + + shouldThrow { + jwtTokenExtractor.extractRefreshToken(request) + } + } + } + + describe("extractId") { + it("parseClaims를 통해 id를 반환한다") { + val token = "sample" + val claims = mockk() + every { jwtProvider.parseClaims(token) } returns claims + every { claims.get("id", Long::class.javaObjectType) } returns 77L + + val id = jwtTokenExtractor.extractId(token) + + id shouldBe 77L + verify(exactly = 1) { jwtProvider.parseClaims(token) } + } + } + + describe("extractClaims") { + it("id, email, tokenType을 함께 반환한다") { + val token = "sample" + val claims = mockk() + every { jwtProvider.parseClaims(token) } returns claims + every { claims.get("id", Long::class.javaObjectType) } returns 77L + every { claims.get("email", String::class.java) } returns "sample@com" + every { claims.get("tokenType", String::class.java) } returns "ACCESS" + + val tokenClaims = jwtTokenExtractor.extractClaims(token) + + tokenClaims?.id shouldBe 77L + tokenClaims?.email shouldBe "sample@com" + tokenClaims?.tokenType shouldBe TokenType.ACCESS + verify(exactly = 1) { jwtProvider.parseClaims(token) } + } + + it("tokenType 클레임이 없으면 기본값 ACCESS를 반환한다") { + val token = "sample" + val claims = mockk() + every { jwtProvider.parseClaims(token) } returns claims + every { claims.get("id", Long::class.javaObjectType) } returns 77L + every { claims.get("email", String::class.java) } returns "sample@com" + every { claims.get("tokenType", String::class.java) } returns null + + val tokenClaims = jwtTokenExtractor.extractClaims(token) + + tokenClaims?.tokenType shouldBe TokenType.ACCESS + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt new file mode 100644 index 00000000..459ab50b --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt @@ -0,0 +1,86 @@ +package com.weeth.global.auth.jwt.application.service + +import com.weeth.global.config.properties.CookieProperties +import com.weeth.global.config.properties.JwtProperties +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain + +class TokenCookieProviderTest : + DescribeSpec({ + val jwtProperties = + JwtProperties( + key = "test-key", + access = JwtProperties.TokenProperties(expiration = 3_600_000L, header = "Authorization"), + refresh = JwtProperties.TokenProperties(expiration = 604_800_000L, header = "Authorization_refresh"), + ) + + describe("createAccessTokenCookie") { + it("설정값대로 access token 쿠키를 생성한다") { + val cookieProperties = + CookieProperties( + accessTokenName = "access_token", + refreshTokenName = "refresh_token", + secure = false, + ) + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.createAccessTokenCookie("test-access-token") + + cookie.name shouldBe "access_token" + cookie.value shouldBe "test-access-token" + cookie.maxAge.seconds shouldBe 3600L + cookie.path shouldBe "/" + cookie.isHttpOnly shouldBe true + cookie.isSecure shouldBe false + cookie.sameSite shouldBe "Lax" + } + + it("domain이 설정되면 쿠키에 도메인이 포함된다") { + val cookieProperties = + CookieProperties( + accessTokenName = "access_token", + refreshTokenName = "refresh_token", + domain = "example.com", + ) + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.createAccessTokenCookie("test-token") + + cookie.toString() shouldContain "Domain=example.com" + } + + it("domain이 빈 문자열이면 쿠키에 도메인이 포함되지 않는다") { + val cookieProperties = + CookieProperties(accessTokenName = "access_token", refreshTokenName = "refresh_token", domain = "") + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.createAccessTokenCookie("test-token") + + cookie.toString().contains("Domain=") shouldBe false + } + } + + describe("createRefreshTokenCookie") { + it("설정값대로 refresh token 쿠키를 생성한다") { + val cookieProperties = + CookieProperties( + accessTokenName = "access_token", + refreshTokenName = "refresh_token", + secure = true, + sameSite = "None", + ) + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.createRefreshTokenCookie("test-refresh-token") + + cookie.name shouldBe "refresh_token" + cookie.value shouldBe "test-refresh-token" + cookie.maxAge.seconds shouldBe 604_800L + cookie.path shouldBe "/api/v4/users/social/refresh" + cookie.isHttpOnly shouldBe true + cookie.isSecure shouldBe true + cookie.sameSite shouldBe "None" + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt new file mode 100644 index 00000000..6f854d3c --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt @@ -0,0 +1,82 @@ +package com.weeth.global.auth.jwt.application.usecase + +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify + +class JwtManageUseCaseTest : + DescribeSpec({ + val jwtProvider = mockk() + val jwtService = mockk() + val refreshTokenStore = mockk(relaxUnitFun = true) + val useCase = JwtManageUseCase(jwtProvider, jwtService, refreshTokenStore) + + beforeTest { clearMocks(jwtProvider, jwtService, refreshTokenStore) } + + describe("create") { + it("ACCESS 타입으로 토큰을 생성하고 저장한다") { + every { jwtProvider.createAccessToken(1L, "a@weeth.com", TokenType.ACCESS) } returns "access" + every { jwtProvider.createRefreshToken(1L) } returns "refresh" + + val result = useCase.create(1L, "a@weeth.com", TokenType.ACCESS) + + result shouldBe JwtDto("access", "refresh") + verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", "a@weeth.com", TokenType.ACCESS) } + } + + it("TEMPORARY 타입으로 토큰을 생성하고 저장한다") { + every { jwtProvider.createAccessToken(1L, "a@weeth.com", TokenType.TEMPORARY) } returns "temp-access" + every { jwtProvider.createRefreshToken(1L) } returns "refresh" + + val result = useCase.create(1L, "a@weeth.com", TokenType.TEMPORARY) + + result shouldBe JwtDto("temp-access", "refresh") + verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", "a@weeth.com", TokenType.TEMPORARY) } + } + } + + describe("reIssueToken") { + it("저장된 tokenType으로 새 토큰을 재발급한다") { + every { jwtProvider.validate("old-refresh") } just runs + every { jwtService.extractId("old-refresh") } returns 10L + every { refreshTokenStore.getEmail(10L) } returns "admin@weeth.com" + every { refreshTokenStore.getTokenType(10L) } returns TokenType.ACCESS + every { jwtProvider.createAccessToken(10L, "admin@weeth.com", TokenType.ACCESS) } returns "new-access" + every { jwtProvider.createRefreshToken(10L) } returns "new-refresh" + + val result = useCase.reIssueToken("old-refresh") + + result shouldBe JwtDto("new-access", "new-refresh") + verify(exactly = 1) { refreshTokenStore.validateRefreshToken(10L, "old-refresh") } + verify(exactly = 1) { refreshTokenStore.save(10L, "new-refresh", "admin@weeth.com", TokenType.ACCESS) } + } + + it("TEMPORARY tokenType이면 TEMPORARY 토큰으로 재발급한다") { + every { jwtProvider.validate("old-refresh") } just runs + every { jwtService.extractId("old-refresh") } returns 10L + every { refreshTokenStore.getEmail(10L) } returns "new@weeth.com" + every { refreshTokenStore.getTokenType(10L) } returns TokenType.TEMPORARY + every { + jwtProvider.createAccessToken(10L, "new@weeth.com", TokenType.TEMPORARY) + } returns "temp-access" + every { jwtProvider.createRefreshToken(10L) } returns "new-refresh" + + val result = useCase.reIssueToken("old-refresh") + + result shouldBe JwtDto("temp-access", "new-refresh") + verify(exactly = 1) { + refreshTokenStore.save(10L, "new-refresh", "new@weeth.com", TokenType.TEMPORARY) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt new file mode 100644 index 00000000..5765fd2f --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt @@ -0,0 +1,44 @@ +package com.weeth.global.auth.jwt.domain.service + +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.config.properties.JwtProperties +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class JwtTokenProviderTest : + StringSpec({ + val jwtProperties = + JwtProperties( + key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + access = JwtProperties.TokenProperties(expiration = 60_000L, header = "Authorization"), + refresh = JwtProperties.TokenProperties(expiration = 120_000L, header = "Authorization_refresh"), + ) + + val jwtProvider = JwtTokenProvider(jwtProperties) + + "access token 생성 후 claims를 파싱할 수 있다" { + val token = jwtProvider.createAccessToken(1L, "test@weeth.com", TokenType.ACCESS) + + val claims = jwtProvider.parseClaims(token) + + claims.get("id", Number::class.java).toLong() shouldBe 1L + claims.get("email", String::class.java) shouldBe "test@weeth.com" + claims.get("tokenType", String::class.java) shouldBe "ACCESS" + } + + "TEMPORARY 토큰은 tokenType 클레임이 TEMPORARY이다" { + val token = jwtProvider.createAccessToken(1L, "test@weeth.com", TokenType.TEMPORARY) + + val claims = jwtProvider.parseClaims(token) + + claims.get("tokenType", String::class.java) shouldBe "TEMPORARY" + } + + "유효하지 않은 토큰 검증 시 InvalidTokenException이 발생한다" { + shouldThrow { + jwtProvider.validate("not-a-token") + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt new file mode 100644 index 00000000..4278102a --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt @@ -0,0 +1,102 @@ +package com.weeth.global.auth.jwt.filter + +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.auth.model.AuthenticatedUser +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder + +class JwtAuthenticationProcessingFilterTest : + DescribeSpec({ + val jwtProvider = mockk() + val jwtService = mockk() + val filter = JwtAuthenticationProcessingFilter(jwtProvider, jwtService) + + beforeTest { + SecurityContextHolder.clearContext() + clearMocks(jwtProvider, jwtService) + } + + afterTest { + SecurityContextHolder.clearContext() + } + + describe("doFilterInternal") { + it("ACCESS 토큰이면 ROLE_USER 권한을 부여한다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns "access-token" + every { jwtProvider.validate("access-token") } just runs + every { jwtService.extractClaims("access-token") } returns + JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", TokenType.ACCESS) + + filter.doFilter(request, response, chain) + + val authentication = SecurityContextHolder.getContext().authentication + (authentication == null) shouldBe false + (authentication.principal is AuthenticatedUser) shouldBe true + val principal = authentication.principal as AuthenticatedUser + principal.id shouldBe 1L + principal.email shouldBe "admin@weeth.com" + authentication.authorities.any { it.authority == "ROLE_USER" } shouldBe true + } + + it("TEMPORARY 토큰이면 ROLE_TEMPORARY 권한을 부여한다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v4/users/terms" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns "temp-token" + every { jwtProvider.validate("temp-token") } just runs + every { jwtService.extractClaims("temp-token") } returns + JwtTokenExtractor.TokenClaims(2L, "new@weeth.com", TokenType.TEMPORARY) + + filter.doFilter(request, response, chain) + + val authentication = SecurityContextHolder.getContext().authentication + (authentication == null) shouldBe false + authentication.authorities.any { it.authority == "ROLE_TEMPORARY" } shouldBe true + authentication.authorities.any { it.authority == "ROLE_USER" } shouldBe false + } + + it("토큰이 없으면 인증을 저장하지 않는다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns null + + filter.doFilter(request, response, chain) + + SecurityContextHolder.getContext().authentication shouldBe null + verify(exactly = 0) { jwtProvider.validate(any()) } + } + + it("claims 추출에 실패하면 인증을 저장하지 않는다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns "access-token" + every { jwtProvider.validate("access-token") } just runs + every { jwtService.extractClaims("access-token") } returns null + + filter.doFilter(request, response, chain) + + SecurityContextHolder.getContext().authentication shouldBe null + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt new file mode 100644 index 00000000..ec292bc4 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt @@ -0,0 +1,98 @@ +package com.weeth.global.auth.jwt.infrastructure.store + +import com.weeth.config.TestContainersConfig +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException +import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.auth.jwt.infrastructure.RedisRefreshTokenStoreAdapter +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.test.context.ActiveProfiles + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class) +class RedisRefreshTokenStoreAdapterTest( + private val redisRefreshTokenStoreAdapter: RedisRefreshTokenStoreAdapter, + private val redisTemplate: RedisTemplate, +) : DescribeSpec({ + beforeTest { + val keys = redisTemplate.keys("$PREFIX*") + if (!keys.isNullOrEmpty()) { + redisTemplate.delete(keys) + } + } + + describe("save/get") { + it("실제 Redis에 email/token/tokenType을 저장하고 조회한다") { + redisRefreshTokenStoreAdapter.save(1L, "rt", "a@weeth.com", TokenType.ACCESS) + + redisRefreshTokenStoreAdapter.getEmail(1L) shouldBe "a@weeth.com" + redisRefreshTokenStoreAdapter.getTokenType(1L) shouldBe TokenType.ACCESS + redisTemplate.opsForHash().get("refreshToken:1", "token") shouldBe "rt" + redisTemplate.opsForHash().get("refreshToken:1", "tokenType") shouldBe "ACCESS" + } + + it("TEMPORARY tokenType을 저장하고 조회한다") { + redisRefreshTokenStoreAdapter.save(5L, "rt", "new@weeth.com", TokenType.TEMPORARY) + + redisRefreshTokenStoreAdapter.getTokenType(5L) shouldBe TokenType.TEMPORARY + } + } + + describe("validateRefreshToken") { + it("저장된 토큰과 일치하면 예외가 발생하지 않는다") { + redisRefreshTokenStoreAdapter.save(2L, "stored", "u@weeth.com", TokenType.ACCESS) + + redisRefreshTokenStoreAdapter.validateRefreshToken(2L, "stored") + } + + it("요청 토큰이 다르면 InvalidTokenException이 발생한다") { + redisRefreshTokenStoreAdapter.save(3L, "stored", "u@weeth.com", TokenType.ACCESS) + + shouldThrow { + redisRefreshTokenStoreAdapter.validateRefreshToken(3L, "different") + } + } + } + + describe("getEmail") { + it("값이 없으면 RedisTokenNotFoundException이 발생한다") { + shouldThrow { + redisRefreshTokenStoreAdapter.getEmail(999L) + } + } + } + + describe("getTokenType") { + it("값이 없으면 기본값 ACCESS를 반환한다") { + // tokenType 필드가 없는 기존 데이터 시뮬레이션 + val key = "refreshToken:998" + redisTemplate.opsForHash().putAll( + key, + mapOf("token" to "rt", "email" to "old@weeth.com"), + ) + + redisRefreshTokenStoreAdapter.getTokenType(998L) shouldBe TokenType.ACCESS + } + } + + describe("delete") { + it("delete 후 조회 시 예외가 발생한다") { + redisRefreshTokenStoreAdapter.save(4L, "rt", "x@weeth.com", TokenType.ACCESS) + redisRefreshTokenStoreAdapter.delete(4L) + + shouldThrow { + redisRefreshTokenStoreAdapter.getEmail(4L) + } + } + } + }) { + companion object { + private const val PREFIX = "refreshToken:" + } +} diff --git a/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt new file mode 100644 index 00000000..0edeaa7e --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt @@ -0,0 +1,66 @@ +package com.weeth.global.auth.resolver + +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException +import com.weeth.global.auth.model.AuthenticatedUser +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import org.springframework.core.MethodParameter +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.context.request.ServletWebRequest + +class CurrentUserArgumentResolverTest : + StringSpec({ + val resolver = CurrentUserArgumentResolver() + + afterTest { + SecurityContextHolder.clearContext() + } + + "@CurrentUser Long 파라미터를 지원한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + + resolver.supportsParameter(parameter) shouldBe true + } + + "인증 컨텍스트가 익명이면 예외가 발생한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + + SecurityContextHolder.getContext().authentication = + AnonymousAuthenticationToken("key", "anonymousUser", listOf(SimpleGrantedAuthority("ROLE_ANONYMOUS"))) + + shouldThrow { + resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + } + } + + "principal이 AuthenticatedUser면 userId를 반환한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + val principal = AuthenticatedUser(id = 99L, email = "test@weeth.com") + SecurityContextHolder.getContext().authentication = + UsernamePasswordAuthenticationToken(principal, null, emptyList()) + + val result = resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + + result shouldBe 99L + } + }) { + private class DummyController { + @Suppress("unused") + fun target( + @CurrentUser userId: Long, + ) { + userId.toString() + } + } +} diff --git a/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt b/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt new file mode 100644 index 00000000..a84d7ee0 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt @@ -0,0 +1,39 @@ +package com.weeth.global.common.exception + +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.springframework.validation.BeanPropertyBindingResult +import org.springframework.validation.BindException +import org.springframework.validation.FieldError + +class CommonExceptionHandlerTest : + DescribeSpec({ + val handler = CommonExceptionHandler() + + describe("handle(BaseException)") { + it("ErrorCode 기반 응답으로 변환한다") { + val ex = object : BaseException(JwtErrorCode.TOKEN_NOT_FOUND) {} + + val response = handler.handle(ex) + + response.statusCode.value() shouldBe 404 + response.body?.code shouldBe JwtErrorCode.TOKEN_NOT_FOUND.code + } + } + + describe("handle(BindException)") { + it("필드 에러 목록을 CommonResponse로 반환한다") { + val bindingResult = BeanPropertyBindingResult(Any(), "request") + bindingResult.addError( + FieldError("request", "name", "", false, emptyArray(), emptyArray(), "must not be blank"), + ) + val ex = BindException(bindingResult) + + val response = handler.handle(ex) + + response.statusCode.value() shouldBe 400 + response.body?.message shouldBe "bindException" + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/common/id/TsidBase62EncoderTest.kt b/src/test/kotlin/com/weeth/global/common/id/TsidBase62EncoderTest.kt new file mode 100644 index 00000000..22acb7cf --- /dev/null +++ b/src/test/kotlin/com/weeth/global/common/id/TsidBase62EncoderTest.kt @@ -0,0 +1,63 @@ +package com.weeth.global.common.id + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class TsidBase62EncoderTest : + StringSpec({ + "Tsid Long이 Base62 String으로 정상 인코딩 된다." { + val cases = + mapOf( + 0L to "0", + 61L to "Z", + 62L to "10", + 375_109L to "1zA9", + ) + + cases.forEach { (tsid, expected) -> + TsidBase62Encoder.encode(tsid) shouldBe expected + } + } + + "Base62 String이 Tsid Long으로 정상 디코딩 된다." { + val cases = + mapOf( + "0" to 0L, + "Z" to 61L, + "10" to 62L, + "1zA9" to 375_109L, + ) + + cases.forEach { (encoded, expected) -> + TsidBase62Encoder.decode(encoded) shouldBe expected + } + } + + "encode 후 decode 하면 원래 값이 나온다" { + val values = + listOf( + 0L, + 1L, + 10L, + 61L, + 62L, + 999L, + 123456789L, + Long.MAX_VALUE, + ) + + values.forEach { value -> + val encoded = TsidBase62Encoder.encode(value) + val decoded = TsidBase62Encoder.decode(encoded) + + decoded shouldBe value + } + } + + "유효하지 않은 Base62 문자가 들어오면 예외가 발생한다" { + shouldThrow { + TsidBase62Encoder.decode("abc!") + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolverTest.kt b/src/test/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolverTest.kt new file mode 100644 index 00000000..7289cb21 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolverTest.kt @@ -0,0 +1,64 @@ +package com.weeth.global.common.web + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import org.springframework.core.MethodParameter +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.web.bind.MissingPathVariableException +import org.springframework.web.context.request.ServletWebRequest +import org.springframework.web.servlet.HandlerMapping + +class TsidPathVariableArgumentResolverTest : + StringSpec({ + val resolver = TsidPathVariableArgumentResolver() + + "@TsidPathVariable Long 파라미터를 지원한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + + resolver.supportsParameter(parameter) shouldBe true + } + + "Base62 path variable을 Long으로 디코딩한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + request.setAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, mapOf("clubId" to "1zA9")) + + val result = resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + + result shouldBe 375_109L + } + + "유효하지 않은 Base62 값이면 예외가 발생한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + request.setAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, mapOf("clubId" to "%%%")) + + shouldThrow { + resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + } + } + + "path variable이 누락되면 예외가 발생한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + request.setAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, emptyMap()) + + shouldThrow { + resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + } + } + }) { + private class DummyController { + @Suppress("unused") + fun target( + @TsidPathVariable("clubId") clubId: Long, + ) { + clubId.toString() + } + } +} diff --git a/src/test/kotlin/com/weeth/global/logging/MaskingJsonGeneratorTest.kt b/src/test/kotlin/com/weeth/global/logging/MaskingJsonGeneratorTest.kt new file mode 100644 index 00000000..9723853a --- /dev/null +++ b/src/test/kotlin/com/weeth/global/logging/MaskingJsonGeneratorTest.kt @@ -0,0 +1,41 @@ +package com.weeth.global.logging + +import com.fasterxml.jackson.core.JsonFactory +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import java.io.StringWriter + +class MaskingJsonGeneratorTest : + StringSpec({ + "snake_case token field values are masked" { + val json = writeJson("access_token", "eyJabcdefghi.secret.payload") + + json shouldContain """"access_token":"***"""" + json shouldNotContain "eyJabcdefghi.secret.payload" + } + + "email and phone number patterns are masked inside log messages" { + val json = writeJson("message", "contact test@example.com or 010-1234-5678") + + json shouldContain "t***@example.com" + json shouldContain "010-****-5678" + json shouldNotContain "test@example.com" + json shouldNotContain "010-1234-5678" + } + }) + +private fun writeJson( + fieldName: String, + value: String, +): String { + val writer = StringWriter() + val generator = MaskingJsonGenerator(JsonFactory().createGenerator(writer)) + + generator.writeStartObject() + generator.writeStringField(fieldName, value) + generator.writeEndObject() + generator.close() + + return writer.toString() +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 4747e28c..fb88f77e 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,6 +1,9 @@ spring: - profiles: - active: test + data: + redis: + host: localhost + port: 6379 + password: jpa: hibernate: ddl-auto: create-drop @@ -9,3 +12,51 @@ spring: hibernate: format_sql: true dialect: org.hibernate.dialect.MySQL8Dialect + generate_statistics: true + +weeth: + jwt: + key: test-jwt-secret-key-test-jwt-secret-key + access: + expiration: 30 + header: Auth + refresh: + expiration: 1440 + header: Refresh + cookie: + access-token-name: access_token + refresh-token-name: refresh_token + domain: "" + secure: false + +auth: + providers: + kakao: + authorize_uri: https://kauth.kakao.com/oauth/authorize + client_id: test-kakao-client-id + redirect_uri: http://localhost/test/kakao/callback + grant_type: authorization_code + token_uri: https://kauth.kakao.com/oauth/token + user_info_uri: https://kapi.kakao.com/v2/user/me + apple: + client_id: test.apple.client + team_id: TESTTEAMID + key_id: TESTKEYID + redirect_uri: http://localhost/test/apple/callback + token_uri: https://appleid.apple.com/auth/token + keys_uri: https://appleid.apple.com/auth/keys + private_key_path: test/AuthKey_TEST.p8 + +career-net: + key: ${CAREER_NET_API_KEY:dummy-key-for-test} + base-url: https://www.career.go.kr/cnet/openapi/getOpenApi + +cloud: + aws: + s3: + bucket: test-bucket + credentials: + access-key: test-access-key + secret-key: test-secret-key + region: + static: ap-northeast-2