diff --git a/.github/workflows/ci-cd_dev.yml b/.github/workflows/ci-cd_dev.yml index 5b32541..d1da28a 100644 --- a/.github/workflows/ci-cd_dev.yml +++ b/.github/workflows/ci-cd_dev.yml @@ -1,4 +1,4 @@ -name: Development Server CI/CD with Gradle and Docker +name: Production Server CI/CD with Gradle and Docker on: push: @@ -49,20 +49,20 @@ jobs: if: github.event_name == 'push' run: docker push ${{ secrets.DOCKER_USERNAME }}/billilge-dev:latest - run-docker-image-on-ec2: - needs: build-docker-image - #push 했을 때만 배포가 진행되도록 - if: github.event_name == 'push' - runs-on: self-hosted - steps: - - name: docker pull - run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/billilge-dev:latest - - - name: docker stop container - run: sudo docker stop springboot || true - - - name: docker run new container - run: sudo docker run --env-file /home/ubuntu/billilge.env --name springboot --rm -d -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/billilge-dev:latest - - - name: delete old docker image - run: sudo docker image prune -f \ No newline at end of file +# run-docker-image-on-ec2: +# needs: build-docker-image +# #push 했을 때만 배포가 진행되도록 +# if: github.event_name == 'push' +# runs-on: self-hosted +# steps: +# - name: docker pull +# run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/billilge:latest +# +# - name: docker stop container +# run: sudo docker stop springboot || true +# +# - name: docker run new container +# run: sudo docker run --env-file /home/ubuntu/billilge.env --name springboot --rm -d -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/billilge:latest +# +# - name: delete old docker image +# run: sudo docker image prune -f \ No newline at end of file diff --git a/.github/workflows/ci-cd_prod.yml b/.github/workflows/ci-cd_prod.yml index ee3bae9..9b30f53 100644 --- a/.github/workflows/ci-cd_prod.yml +++ b/.github/workflows/ci-cd_prod.yml @@ -49,20 +49,20 @@ jobs: if: github.event_name == 'push' run: docker push ${{ secrets.DOCKER_USERNAME }}/billilge:latest - run-docker-image-on-ec2: - needs: build-docker-image - #push 했을 때만 배포가 진행되도록 - if: github.event_name == 'push' - runs-on: self-hosted - steps: - - name: docker pull - run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/billilge:latest - - - name: docker stop container - run: sudo docker stop springboot || true - - - name: docker run new container - run: sudo docker run --env-file /home/ubuntu/billilge.env --name springboot --rm -d -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/billilge:latest - - - name: delete old docker image - run: sudo docker image prune -f \ No newline at end of file +# run-docker-image-on-ec2: +# needs: build-docker-image +# #push 했을 때만 배포가 진행되도록 +# if: github.event_name == 'push' +# runs-on: self-hosted +# steps: +# - name: docker pull +# run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/billilge:latest +# +# - name: docker stop container +# run: sudo docker stop springboot || true +# +# - name: docker run new container +# run: sudo docker run --env-file /home/ubuntu/billilge.env --name springboot --rm -d -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/billilge:latest +# +# - name: delete old docker image +# run: sudo docker image prune -f \ No newline at end of file diff --git a/.gitignore b/.gitignore index aaef4e1..d415143 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,4 @@ application-local.yml .DS_Store /src/main/resources/firebase -/src/main/resources/payer_insert_queries.sql +/src/main/resources/*.sql diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e93eadf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,182 @@ +# Billilge Backend + +대학교 학생회 물품 대여 관리 시스템 백엔드 + +## 기술 스택 + +- **Spring Boot 3.4.1** / **Kotlin 1.9.25** / **JDK 21** +- **JPA + QueryDSL** (MySQL) +- **Spring Security** (JWT + Google OAuth2) +- **Firebase Cloud Messaging** (푸시 알림) +- **AWS S3** (이미지 업로드) +- **Apache POI** (Excel 생성) + +## 빌드 & 실행 + +```bash +./gradlew compileKotlin # 컴파일 확인 +./gradlew build # 전체 빌드 +./gradlew bootRun # 실행 +``` + +## 아키텍처 + +``` +Controller → Facade → Service → Repository +``` + +| 레이어 | 역할 | DTO 참조 | +|--------|------|----------| +| **Controller** | HTTP 요청/응답, `@AuthenticationPrincipal`로 인증 정보 추출 | Request/Response DTO | +| **Facade** | Request DTO 분해, 크로스 도메인 조합, Response DTO 생성 | Request/Response DTO | +| **Service** | 비즈니스 로직, 자기 도메인 Repository만 사용 | Entity/primitives만 | +| **Repository** | 데이터 접근 (JPA + QueryDSL) | Entity/Query DTO만 | + +### 핵심 규칙 + +- **Service는 Request/Response DTO를 참조하지 않는다** — Entity, primitives, 글로벌 DTO(`PageableCondition`, `SearchCondition`)만 사용 +- **Service는 타 도메인 Repository를 직접 의존하지 않는다** — 타 도메인 Service를 통해 접근 (예외: `PayerService → MemberRepository` 순환 의존 방지) +- **크로스 도메인 조합은 Facade에서 수행한다** — Facade가 여러 Service를 호출해 엔티티를 조합 후 Service에 전달 +- **Facade에서 트랜잭션 필요 시 `@Transactional` 명시** — 여러 서비스 호출을 하나의 persistence context로 묶어야 할 때 + +### 트랜잭션 패턴 + +- Service 클래스에 `@Transactional(readOnly = true)` 기본 적용 +- 쓰기 메서드만 `@Transactional`로 오버라이드 +- JPA dirty checking 활용 — `repository.save()` 없이 엔티티 필드 변경으로 자동 반영 + +## 도메인 구조 + +``` +domain/ +├── item/ # 물품 관리 +├── member/ # 회원, 인증 +├── notification/ # 알림 (FCM 푸시) +├── payer/ # 회비 납부자 관리 +└── rental/ # 대여/반납 관리 +``` + +각 도메인 패키지 구조: +``` +domain/{name}/ +├── controller/ # API 컨트롤러 + Api 인터페이스 (Swagger) +├── facade/ # Facade (DTO 변환, 크로스 도메인 조합) +├── service/ # 비즈니스 로직 +├── repository/ # JPA Repository + Custom(QueryDSL) +├── entity/ # JPA Entity +├── dto/ # request/, response/ +├── enums/ # 도메인 열거형 +└── exception/ # 도메인 에러 코드 +``` + +## 서비스 의존성 + +``` +ItemService → ItemRepository, S3Service +MemberService → MemberRepository, TokenProvider, PayerService +NotificationService → NotificationRepository, FCMService, MemberService +PayerService → PayerRepository, MemberRepository, ExcelGenerator +RentalService → RentalRepository, NotificationService +``` + +## 대여 상태 머신 + +``` +[사용자 신청] PENDING → CONFIRMED → RENTAL → RETURN_PENDING → RETURN_CONFIRMED → RETURNED + → REJECTED + PENDING → CANCEL (사용자 취소) + +[관리자 생성] 대여물품: → RENTAL (바로 대여중) + 소모품: → RETURNED (즉시 반납 처리) +``` + +- **CONFIRMED**: 재고 차감, 담당자(worker) 배정 +- **RETURNED**: 재고 복원 (소모품 제외) +- **소모품(CONSUMPTION)**: RENTAL 상태 요청 시 자동으로 RETURNED 처리 + +## 대여 비즈니스 규칙 + +- 회비 납부자(`isFeePaid`)만 대여 가능 +- 동일 물품 중복 대여 불가 (`ignoreDuplicate`로 우회 가능) +- 시험 기간 대여 불가 (`exam-period.start-date` / `end-date`) +- 주말 대여 불가 +- 과거 시간 대여 불가 +- 10시~17시만 대여 가능 +- Dev 모드(`/rentals/dev`): 시간 검증 생략, ADMIN 역할 필요 + +## API 엔드포인트 + +### 인증 (Public) +| Method | Path | 설명 | +|--------|------|------| +| POST | `/auth/sign-up` | 회원가입 | +| POST | `/auth/admin-login` | 관리자 로그인 | + +### 물품 (Public) +| Method | Path | 설명 | +|--------|------|------| +| GET | `/items` | 물품 검색 | + +### 회원 (JWT 필요) +| Method | Path | 설명 | +|--------|------|------| +| POST | `/members/me/fcm-token` | FCM 토큰 등록 | + +### 알림 (JWT 필요) +| Method | Path | 설명 | +|--------|------|------| +| GET | `/notifications` | 알림 목록 | +| GET | `/notifications/count` | 안읽은 알림 수 | +| PATCH | `/notifications/{id}` | 알림 읽음 | +| PATCH | `/notifications/all` | 전체 읽음 | + +### 대여 (JWT 필요) +| Method | Path | 설명 | +|--------|------|------| +| POST | `/rentals` | 대여 신청 | +| POST | `/rentals/dev` | 개발용 대여 (시간 검증 생략) | +| GET | `/rentals` | 대여 이력 조회 | +| PATCH | `/rentals/{id}` | 대여 취소 | +| PATCH | `/rentals/return/{id}` | 반납 신청 | +| GET | `/rentals/return-required` | 반납 필요 목록 | + +### 관리자 (JWT + @OnlyAdmin) +| Method | Path | 설명 | +|--------|------|------| +| GET | `/admin/items` | 물품 목록 (대여자 수 포함) | +| POST | `/admin/items` | 물품 추가 | +| PUT | `/admin/items/{id}` | 물품 수정 | +| GET | `/admin/items/{id}` | 물품 상세 | +| DELETE | `/admin/items/{id}` | 물품 삭제 | +| GET | `/admin/members` | 회원 목록 | +| GET | `/admin/members/admins` | 관리자 목록 | +| POST | `/admin/members/admins` | 관리자 등록 | +| DELETE | `/admin/members/admins` | 관리자 해제 | +| GET | `/admin/members/payers` | 납부자 목록 | +| POST | `/admin/members/payers` | 납부자 등록 | +| DELETE | `/admin/members/payers` | 납부자 삭제 | +| GET | `/admin/members/payers/excel` | 납부자 엑셀 다운로드 | +| GET | `/admin/notifications` | 관리자 알림 | +| GET | `/admin/rentals/dashboard` | 대시보드 | +| GET | `/admin/rentals` | 대여 이력 | +| PATCH | `/admin/rentals/{id}` | 대여 상태 변경 | +| POST | `/admin/rentals` | 관리자 대여 생성 | +| DELETE | `/admin/rentals/{id}` | 대여 이력 삭제 | + +## Global 패키지 + +``` +global/ +├── annotation/ # @OnlyAdmin +├── config/ # SecurityConfig, CorsConfig, SwaggerConfig, QueryDslConfig, AsyncConfig +├── dto/ # PageableCondition, SearchCondition, PageableResponse +├── exception/ # ApiException, ErrorCode, GlobalExceptionHandler +├── external/ +│ ├── fcm/ # FCMConfig, FCMService +│ └── s3/ # S3Config, S3Service +├── logging/ # LoggingFilter +├── security/ +│ ├── jwt/ # TokenProvider, TokenAuthenticationFilter +│ └── oauth2/ # Google OAuth2 핸들러, UserAuthInfo +└── utils/ # DateUtils(isWeekend), ExcelGenerator +``` diff --git a/Dockerfile b/Dockerfile index 9f20eed..c96189a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Dockerfile -# jdk17 Image Start -FROM openjdk:17 +# jdk21 Image Start +FROM eclipse-temurin:21-jre # jar 파일 복제 COPY build/libs/*.jar app.jar diff --git a/Dockerfile-dev b/Dockerfile-dev index 3721652..299df25 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -1,7 +1,7 @@ # Dockerfile -# jdk17 Image Start -FROM openjdk:17 +# jdk21 Image Start +FROM eclipse-temurin:21-jre # jar 파일 복제 COPY build/libs/*.jar app.jar diff --git a/build.gradle.kts b/build.gradle.kts index b402918..b60b484 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,7 +13,7 @@ version = "0.0.1-SNAPSHOT" java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } @@ -47,7 +47,7 @@ dependencies { implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6") - implementation("io.awspring.cloud:spring-cloud-starter-aws:2.4.4") + implementation("io.minio:minio:8.5.7") implementation("javax.xml.bind:jaxb-api:2.3.1") implementation("org.apache.tika:tika-core:2.9.2") implementation("org.apache.tika:tika-parsers-standard-package:2.9.2") @@ -70,6 +70,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.springframework.security:spring-security-test") + runtimeOnly("com.h2database:h2") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/controller/AdminConfigValueApi.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/controller/AdminConfigValueApi.kt new file mode 100644 index 0000000..881102c --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/controller/AdminConfigValueApi.kt @@ -0,0 +1,74 @@ +package site.billilge.api.backend.domain.configvalue.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import site.billilge.api.backend.domain.configvalue.dto.request.ChangeAdminPasswordRequest +import site.billilge.api.backend.domain.configvalue.dto.request.ConfigValueBulkUpdateRequest +import site.billilge.api.backend.domain.configvalue.dto.request.ConfigValueUpdateRequest +import site.billilge.api.backend.domain.configvalue.dto.response.ConfigValueDetail +import site.billilge.api.backend.domain.configvalue.dto.response.ConfigValueFindAllResponse + +@Tag(name = "(Admin) ConfigValue", description = "관리자용 설정값 API") +interface AdminConfigValueApi { + @Operation( + summary = "설정값 조회", + description = "키로 설정값을 조회하는 API" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "설정값 조회 성공"), + ApiResponse(responseCode = "404", description = "설정값을 찾을 수 없습니다.") + ] + ) + fun getByKey(@RequestParam key: String): ResponseEntity + + @Operation( + summary = "설정값 일괄 조회", + description = "여러 키로 설정값을 일괄 조회하는 API" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "설정값 일괄 조회 성공") + ] + ) + fun getAllByKeys(@RequestParam keys: List): ResponseEntity + + @Operation( + summary = "설정값 수정", + description = "설정값을 수정하는 API (존재하지 않으면 생성)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "설정값 수정 성공") + ] + ) + fun update(@RequestBody request: ConfigValueUpdateRequest): ResponseEntity + + @Operation( + summary = "설정값 일괄 수정", + description = "여러 설정값을 일괄 수정하는 API (존재하지 않으면 생성)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "설정값 일괄 수정 성공") + ] + ) + fun updateAll(@RequestBody request: ConfigValueBulkUpdateRequest): ResponseEntity + + @Operation( + summary = "관리자 비밀번호 변경", + description = "현재 비밀번호를 검증 후 새 비밀번호로 변경하는 API" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "비밀번호 변경 성공"), + ApiResponse(responseCode = "400", description = "현재 비밀번호가 일치하지 않습니다.") + ] + ) + fun changeAdminPassword(@RequestBody request: ChangeAdminPasswordRequest): ResponseEntity +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/controller/AdminConfigValueController.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/controller/AdminConfigValueController.kt new file mode 100644 index 0000000..8b8015c --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/controller/AdminConfigValueController.kt @@ -0,0 +1,48 @@ +package site.billilge.api.backend.domain.configvalue.controller + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import site.billilge.api.backend.domain.configvalue.dto.request.ChangeAdminPasswordRequest +import site.billilge.api.backend.domain.configvalue.dto.request.ConfigValueBulkUpdateRequest +import site.billilge.api.backend.domain.configvalue.dto.request.ConfigValueUpdateRequest +import site.billilge.api.backend.domain.configvalue.dto.response.ConfigValueDetail +import site.billilge.api.backend.domain.configvalue.dto.response.ConfigValueFindAllResponse +import site.billilge.api.backend.domain.configvalue.facade.ConfigValueFacade +import site.billilge.api.backend.domain.member.enums.Role +import site.billilge.api.backend.global.annotation.OnlyAdmin + +@RestController +@RequestMapping("/admin/config-values") +@OnlyAdmin(roles = [Role.ADMIN, Role.GA]) +class AdminConfigValueController( + private val configValueFacade: ConfigValueFacade, +) : AdminConfigValueApi { + + @GetMapping + override fun getByKey(@RequestParam key: String): ResponseEntity { + return ResponseEntity.ok(configValueFacade.getByKey(key)) + } + + @GetMapping("/bulk") + override fun getAllByKeys(@RequestParam keys: List): ResponseEntity { + return ResponseEntity.ok(configValueFacade.getAllByKeys(keys)) + } + + @PutMapping + override fun update(@RequestBody request: ConfigValueUpdateRequest): ResponseEntity { + configValueFacade.update(request) + return ResponseEntity.ok().build() + } + + @PutMapping("/bulk") + override fun updateAll(@RequestBody request: ConfigValueBulkUpdateRequest): ResponseEntity { + configValueFacade.updateAll(request) + return ResponseEntity.ok().build() + } + + @PatchMapping("/password") + override fun changeAdminPassword(@RequestBody request: ChangeAdminPasswordRequest): ResponseEntity { + configValueFacade.changeAdminPassword(request) + return ResponseEntity.ok().build() + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/request/ChangeAdminPasswordRequest.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/request/ChangeAdminPasswordRequest.kt new file mode 100644 index 0000000..faccf22 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/request/ChangeAdminPasswordRequest.kt @@ -0,0 +1,11 @@ +package site.billilge.api.backend.domain.configvalue.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema +data class ChangeAdminPasswordRequest( + @field:Schema(description = "현재 비밀번호", example = "0412") + val currentPassword: String, + @field:Schema(description = "새 비밀번호", example = "1234") + val newPassword: String, +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/request/ConfigValueBulkUpdateRequest.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/request/ConfigValueBulkUpdateRequest.kt new file mode 100644 index 0000000..6a51e05 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/request/ConfigValueBulkUpdateRequest.kt @@ -0,0 +1,10 @@ +package site.billilge.api.backend.domain.configvalue.dto.request + +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Schema + +@Schema +data class ConfigValueBulkUpdateRequest( + @field:ArraySchema(schema = Schema(implementation = ConfigValueUpdateRequest::class)) + val configValues: List, +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/request/ConfigValueUpdateRequest.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/request/ConfigValueUpdateRequest.kt new file mode 100644 index 0000000..eda4a99 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/request/ConfigValueUpdateRequest.kt @@ -0,0 +1,11 @@ +package site.billilge.api.backend.domain.configvalue.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema +data class ConfigValueUpdateRequest( + @field:Schema(description = "설정 키", example = "exam-period.start-date") + val key: String, + @field:Schema(description = "설정 값", example = "2025-04-14") + val value: String, +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/response/ConfigValueDetail.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/response/ConfigValueDetail.kt new file mode 100644 index 0000000..d0c7e5e --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/response/ConfigValueDetail.kt @@ -0,0 +1,22 @@ +package site.billilge.api.backend.domain.configvalue.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import site.billilge.api.backend.domain.configvalue.entity.ConfigValue + +@Schema +data class ConfigValueDetail( + @field:Schema(description = "설정 키", example = "exam-period.start-date") + val key: String, + @field:Schema(description = "설정 값", example = "2025-04-14") + val value: String, +) { + companion object { + @JvmStatic + fun from(configValue: ConfigValue): ConfigValueDetail { + return ConfigValueDetail( + key = configValue.key, + value = configValue.value, + ) + } + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/response/ConfigValueFindAllResponse.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/response/ConfigValueFindAllResponse.kt new file mode 100644 index 0000000..406f960 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/dto/response/ConfigValueFindAllResponse.kt @@ -0,0 +1,10 @@ +package site.billilge.api.backend.domain.configvalue.dto.response + +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Schema + +@Schema +data class ConfigValueFindAllResponse( + @field:ArraySchema(schema = Schema(implementation = ConfigValueDetail::class)) + val configValues: List, +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/entity/ConfigValue.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/entity/ConfigValue.kt new file mode 100644 index 0000000..3863d7e --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/entity/ConfigValue.kt @@ -0,0 +1,20 @@ +package site.billilge.api.backend.domain.configvalue.entity + +import jakarta.persistence.* + +@Entity +@Table(name = "admin_config_values") +class ConfigValue( + @Column(name = "config_key", nullable = false, unique = true) + val key: String, + @Column(name = "config_value", nullable = false) + var value: String, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null + + fun updateValue(value: String) { + this.value = value + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/enums/ConfigValueKeys.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/enums/ConfigValueKeys.kt new file mode 100644 index 0000000..dc19dd5 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/enums/ConfigValueKeys.kt @@ -0,0 +1,9 @@ +package site.billilge.api.backend.domain.configvalue.enums + +enum class ConfigValueKeys( + val key: String, +) { + EXAM_PERIOD_START_DATE("exam-period.start-date"), + EXAM_PERIOD_END_DATE("exam-period.end-date"), + ADMIN_PASSWORD("login.admin-password"), +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/exception/ConfigValueErrorCode.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/exception/ConfigValueErrorCode.kt new file mode 100644 index 0000000..94dee72 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/exception/ConfigValueErrorCode.kt @@ -0,0 +1,12 @@ +package site.billilge.api.backend.domain.configvalue.exception + +import org.springframework.http.HttpStatus +import site.billilge.api.backend.global.exception.ErrorCode + +enum class ConfigValueErrorCode( + override val message: String, + override val httpStatus: HttpStatus +) : ErrorCode { + CONFIG_VALUE_NOT_FOUND("설정값을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + ADMIN_PASSWORD_MISMATCH("현재 비밀번호가 일치하지 않습니다.", HttpStatus.BAD_REQUEST), +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/facade/ConfigValueFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/facade/ConfigValueFacade.kt new file mode 100644 index 0000000..cd8df11 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/facade/ConfigValueFacade.kt @@ -0,0 +1,35 @@ +package site.billilge.api.backend.domain.configvalue.facade + +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.configvalue.dto.request.ChangeAdminPasswordRequest +import site.billilge.api.backend.domain.configvalue.dto.request.ConfigValueBulkUpdateRequest +import site.billilge.api.backend.domain.configvalue.dto.request.ConfigValueUpdateRequest +import site.billilge.api.backend.domain.configvalue.dto.response.ConfigValueDetail +import site.billilge.api.backend.domain.configvalue.dto.response.ConfigValueFindAllResponse +import site.billilge.api.backend.domain.configvalue.service.ConfigValueService + +@Component +class ConfigValueFacade( + private val configValueService: ConfigValueService, +) { + fun getByKey(key: String): ConfigValueDetail { + return ConfigValueDetail.from(configValueService.getByKey(key)) + } + + fun getAllByKeys(keys: List): ConfigValueFindAllResponse { + val details = configValueService.getAllByKeys(keys).map { ConfigValueDetail.from(it) } + return ConfigValueFindAllResponse(details) + } + + fun update(request: ConfigValueUpdateRequest) { + configValueService.upsert(request.key, request.value) + } + + fun updateAll(request: ConfigValueBulkUpdateRequest) { + request.configValues.forEach { configValueService.upsert(it.key, it.value) } + } + + fun changeAdminPassword(request: ChangeAdminPasswordRequest) { + configValueService.changeAdminPassword(request.currentPassword, request.newPassword) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/repository/ConfigValueRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/repository/ConfigValueRepository.kt new file mode 100644 index 0000000..727aac3 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/repository/ConfigValueRepository.kt @@ -0,0 +1,10 @@ +package site.billilge.api.backend.domain.configvalue.repository + +import org.springframework.data.jpa.repository.JpaRepository +import site.billilge.api.backend.domain.configvalue.entity.ConfigValue +import java.util.* + +interface ConfigValueRepository : JpaRepository { + fun findByKey(key: String): Optional + fun findAllByKeyIn(keys: List): List +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/configvalue/service/ConfigValueService.kt b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/service/ConfigValueService.kt new file mode 100644 index 0000000..eecffa6 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/configvalue/service/ConfigValueService.kt @@ -0,0 +1,52 @@ +package site.billilge.api.backend.domain.configvalue.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import site.billilge.api.backend.domain.configvalue.entity.ConfigValue +import site.billilge.api.backend.domain.configvalue.enums.ConfigValueKeys +import site.billilge.api.backend.domain.configvalue.exception.ConfigValueErrorCode +import site.billilge.api.backend.domain.configvalue.repository.ConfigValueRepository +import site.billilge.api.backend.global.exception.ApiException + +@Service +@Transactional(readOnly = true) +class ConfigValueService( + private val configValueRepository: ConfigValueRepository, +) { + fun getByKey(key: String): ConfigValue { + return configValueRepository.findByKey(key) + .orElseThrow { ApiException(ConfigValueErrorCode.CONFIG_VALUE_NOT_FOUND) } + } + + fun getValueByKey(key: String): String { + return getByKey(key).value + } + + fun getAllByKeys(keys: List): List { + return configValueRepository.findAllByKeyIn(keys) + } + + fun getMapByKeys(keys: List): Map = getAllByKeys(keys).associateBy( + { it.key }, + { it.value } + ) + + @Transactional + fun upsert(key: String, value: String) { + val configValue = configValueRepository.findByKey(key) + if (configValue.isPresent) { + configValue.get().updateValue(value) + } else { + configValueRepository.save(ConfigValue(key = key, value = value)) + } + } + + @Transactional + fun changeAdminPassword(currentPassword: String, newPassword: String) { + val configValue = getByKey(ConfigValueKeys.ADMIN_PASSWORD.key) + if (configValue.value != currentPassword) { + throw ApiException(ConfigValueErrorCode.ADMIN_PASSWORD_MISMATCH) + } + configValue.updateValue(newPassword) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayApi.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayApi.kt new file mode 100644 index 0000000..9bf836e --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayApi.kt @@ -0,0 +1,26 @@ +package site.billilge.api.backend.domain.display.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import site.billilge.api.backend.domain.display.dto.response.DisplayResponse + +@Tag(name = "Display", description = "디스플레이 조회 API") +interface DisplayApi { + + @Operation( + summary = "디스플레이 데이터 조회", + description = "활성 포스터, 일정 캘린더(±3일), 복지물품 현황을 조회하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "디스플레이 데이터 조회 성공" + ) + ] + ) + fun getDisplay(): ResponseEntity +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayCalendarScheduleApi.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayCalendarScheduleApi.kt new file mode 100644 index 0000000..c16c6a1 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayCalendarScheduleApi.kt @@ -0,0 +1,100 @@ +package site.billilge.api.backend.domain.display.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import site.billilge.api.backend.domain.display.dto.request.DisplayCalendarScheduleRequest +import site.billilge.api.backend.domain.display.dto.response.DisplayCalendarScheduleFindAllResponse +import site.billilge.api.backend.global.exception.ErrorResponse +import java.time.LocalDate + +@Tag(name = "(Admin) Display Calendar Schedule", description = "관리자용 전광판 일정 API") +interface DisplayCalendarScheduleApi { + + @Operation( + summary = "기간별 일정 조회", + description = "시작일과 종료일 사이의 일정 목록을 조회하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "일정 조회 성공" + ) + ] + ) + fun getSchedules( + @Parameter(description = "조회 시작일", example = "2025-03-01") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate, + @Parameter(description = "조회 종료일", example = "2025-03-31") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate + ): ResponseEntity + + @Operation( + summary = "일정 추가", + description = "해당일 일정을 추가하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "일정 추가 성공" + ) + ] + ) + fun addSchedule( + @RequestBody request: DisplayCalendarScheduleRequest + ): ResponseEntity + + @Operation( + summary = "일정 수정", + description = "해당일 일정을 수정하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "일정 수정 성공" + ), + ApiResponse( + responseCode = "404", + description = "일정을 찾을 수 없습니다.", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + fun updateSchedule( + @PathVariable id: Long, + @RequestBody request: DisplayCalendarScheduleRequest + ): ResponseEntity + + @Operation( + summary = "일정 삭제", + description = "해당일 일정을 삭제하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "204", + description = "일정 삭제 성공" + ), + ApiResponse( + responseCode = "404", + description = "일정을 찾을 수 없습니다.", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + fun deleteSchedule( + @PathVariable id: Long + ): ResponseEntity +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayCalendarScheduleController.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayCalendarScheduleController.kt new file mode 100644 index 0000000..9533f10 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayCalendarScheduleController.kt @@ -0,0 +1,51 @@ +package site.billilge.api.backend.domain.display.controller + +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import site.billilge.api.backend.domain.display.dto.request.DisplayCalendarScheduleRequest +import site.billilge.api.backend.domain.display.dto.response.DisplayCalendarScheduleFindAllResponse +import site.billilge.api.backend.domain.display.facade.DisplayCalendarScheduleFacade +import site.billilge.api.backend.global.annotation.OnlyAdmin +import java.time.LocalDate + +@RestController +@RequestMapping("/display/calendar-schedules") +@OnlyAdmin +class DisplayCalendarScheduleController( + private val displayCalendarScheduleFacade: DisplayCalendarScheduleFacade, +) : DisplayCalendarScheduleApi { + @GetMapping + override fun getSchedules( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate + ): ResponseEntity { + return ResponseEntity.ok(displayCalendarScheduleFacade.getSchedules(startDate, endDate)) + } + + @PostMapping + override fun addSchedule( + @RequestBody request: DisplayCalendarScheduleRequest + ): ResponseEntity { + displayCalendarScheduleFacade.addSchedule(request) + return ResponseEntity.status(HttpStatus.CREATED).build() + } + + @PatchMapping("/{id}") + override fun updateSchedule( + @PathVariable id: Long, + @RequestBody request: DisplayCalendarScheduleRequest + ): ResponseEntity { + displayCalendarScheduleFacade.updateSchedule(id, request) + return ResponseEntity.ok().build() + } + + @DeleteMapping("/{id}") + override fun deleteSchedule( + @PathVariable id: Long + ): ResponseEntity { + displayCalendarScheduleFacade.deleteSchedule(id) + return ResponseEntity.noContent().build() + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayController.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayController.kt new file mode 100644 index 0000000..48a430d --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayController.kt @@ -0,0 +1,20 @@ +package site.billilge.api.backend.domain.display.controller + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import site.billilge.api.backend.domain.display.dto.response.DisplayResponse +import site.billilge.api.backend.domain.display.facade.DisplayFacade + +@RestController +@RequestMapping("/display") +class DisplayController( + private val displayFacade: DisplayFacade, +) : DisplayApi { + + @GetMapping + override fun getDisplay(): ResponseEntity { + return ResponseEntity.ok(displayFacade.getDisplay()) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayPosterApi.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayPosterApi.kt new file mode 100644 index 0000000..49d06ba --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayPosterApi.kt @@ -0,0 +1,153 @@ +package site.billilge.api.backend.domain.display.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Encoding +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.multipart.MultipartFile +import site.billilge.api.backend.domain.display.dto.request.DisplayPosterRequest +import site.billilge.api.backend.domain.display.dto.response.DisplayPosterFindAllResponse +import site.billilge.api.backend.global.exception.ErrorResponse + +@Tag(name = "(Admin) Display Poster", description = "관리자용 전광판 포스터 API") +interface DisplayPosterApi { + + @Operation( + summary = "포스터 목록 조회", + description = "전체 포스터 목록을 조회하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "포스터 목록 조회 성공" + ) + ] + ) + fun getAllPosters(): ResponseEntity + + @Operation( + summary = "포스터 추가", + description = "포스터를 추가하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "포스터 추가 성공" + ) + ] + ) + @RequestBody( + content = [Content( + encoding = [ + Encoding(name = "posterRequest", contentType = MediaType.APPLICATION_JSON_VALUE) + ] + )] + ) + fun addPoster( + @RequestPart image: MultipartFile, + @RequestPart posterRequest: DisplayPosterRequest + ): ResponseEntity + + @Operation( + summary = "포스터 수정", + description = "포스터 정보를 수정하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "포스터 수정 성공" + ), + ApiResponse( + responseCode = "404", + description = "포스터를 찾을 수 없습니다.", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @RequestBody( + content = [Content( + encoding = [ + Encoding(name = "posterRequest", contentType = MediaType.APPLICATION_JSON_VALUE) + ] + )] + ) + fun updatePoster( + @PathVariable posterId: Long, + @RequestPart(required = false) image: MultipartFile?, + @RequestPart posterRequest: DisplayPosterRequest + ): ResponseEntity + + @Operation( + summary = "포스터 삭제", + description = "포스터를 삭제하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "204", + description = "포스터 삭제 성공" + ), + ApiResponse( + responseCode = "404", + description = "포스터를 찾을 수 없습니다.", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + fun deletePoster( + @PathVariable posterId: Long + ): ResponseEntity + + @Operation( + summary = "포스터 활성화", + description = "포스터를 활성화하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "포스터 활성화 성공" + ), + ApiResponse( + responseCode = "404", + description = "포스터를 찾을 수 없습니다.", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + fun activatePoster( + @PathVariable posterId: Long + ): ResponseEntity + + @Operation( + summary = "포스터 비활성화", + description = "포스터를 비활성화하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "포스터 비활성화 성공" + ), + ApiResponse( + responseCode = "404", + description = "포스터를 찾을 수 없습니다.", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + fun deactivatePoster( + @PathVariable posterId: Long + ): ResponseEntity +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayPosterController.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayPosterController.kt new file mode 100644 index 0000000..f5c6608 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/controller/DisplayPosterController.kt @@ -0,0 +1,66 @@ +package site.billilge.api.backend.domain.display.controller + +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile +import site.billilge.api.backend.domain.display.dto.request.DisplayPosterRequest +import site.billilge.api.backend.domain.display.dto.response.DisplayPosterFindAllResponse +import site.billilge.api.backend.domain.display.facade.DisplayPosterFacade +import site.billilge.api.backend.global.annotation.OnlyAdmin + +@RestController +@RequestMapping("/display/posters") +@OnlyAdmin +class DisplayPosterController( + private val displayPosterFacade: DisplayPosterFacade, +) : DisplayPosterApi { + @GetMapping + override fun getAllPosters(): ResponseEntity { + return ResponseEntity.ok(displayPosterFacade.getAllPosters()) + } + + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + override fun addPoster( + @RequestPart image: MultipartFile, + @RequestPart posterRequest: DisplayPosterRequest + ): ResponseEntity { + displayPosterFacade.addPoster(image, posterRequest) + return ResponseEntity.status(HttpStatus.CREATED).build() + } + + @PatchMapping("/{posterId}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + override fun updatePoster( + @PathVariable posterId: Long, + @RequestPart(required = false) image: MultipartFile?, + @RequestPart posterRequest: DisplayPosterRequest + ): ResponseEntity { + displayPosterFacade.updatePoster(posterId, image, posterRequest) + return ResponseEntity.ok().build() + } + + @DeleteMapping("/{posterId}") + override fun deletePoster( + @PathVariable posterId: Long + ): ResponseEntity { + displayPosterFacade.deletePoster(posterId) + return ResponseEntity.noContent().build() + } + + @PostMapping("/{posterId}/activate") + override fun activatePoster( + @PathVariable posterId: Long + ): ResponseEntity { + displayPosterFacade.activatePoster(posterId) + return ResponseEntity.ok().build() + } + + @DeleteMapping("/{posterId}/deactivate") + override fun deactivatePoster( + @PathVariable posterId: Long + ): ResponseEntity { + displayPosterFacade.deactivatePoster(posterId) + return ResponseEntity.ok().build() + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/dto/request/DisplayCalendarScheduleRequest.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/dto/request/DisplayCalendarScheduleRequest.kt new file mode 100644 index 0000000..373e031 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/dto/request/DisplayCalendarScheduleRequest.kt @@ -0,0 +1,12 @@ +package site.billilge.api.backend.domain.display.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +@Schema +data class DisplayCalendarScheduleRequest( + @field:Schema(description = "일정 날짜", example = "2025-03-15") + val date: LocalDate, + @field:Schema(description = "일정 목록", example = "[\"학생회 회의\", \"동아리 박람회\"]") + val schedules: List, +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/dto/request/DisplayPosterRequest.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/dto/request/DisplayPosterRequest.kt new file mode 100644 index 0000000..415edf3 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/dto/request/DisplayPosterRequest.kt @@ -0,0 +1,9 @@ +package site.billilge.api.backend.domain.display.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema +data class DisplayPosterRequest( + @field:Schema(description = "포스터 제목", example = "학생회 행사 포스터") + val title: String, +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/dto/response/DisplayCalendarScheduleFindAllResponse.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/dto/response/DisplayCalendarScheduleFindAllResponse.kt new file mode 100644 index 0000000..6f24c67 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/dto/response/DisplayCalendarScheduleFindAllResponse.kt @@ -0,0 +1,33 @@ +package site.billilge.api.backend.domain.display.dto.response + +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Schema +import site.billilge.api.backend.domain.display.entity.DisplayCalendarSchedule +import java.time.LocalDate + +@Schema +data class DisplayCalendarScheduleFindAllResponse( + @field:ArraySchema(schema = Schema(implementation = DisplayCalendarScheduleDetail::class)) + val schedules: List, +) { + @Schema + data class DisplayCalendarScheduleDetail( + @field:Schema(description = "일정 ID", example = "1") + val scheduleId: Long, + @field:Schema(description = "일정 날짜", example = "2025-03-15") + val date: LocalDate, + @field:Schema(description = "일정 목록", example = "[\"학생회 회의\", \"동아리 박람회\"]") + val schedules: List, + ) { + companion object { + @JvmStatic + fun from(schedule: DisplayCalendarSchedule): DisplayCalendarScheduleDetail { + return DisplayCalendarScheduleDetail( + scheduleId = schedule.id!!, + date = schedule.date, + schedules = schedule.scheduleList, + ) + } + } + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/dto/response/DisplayPosterFindAllResponse.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/dto/response/DisplayPosterFindAllResponse.kt new file mode 100644 index 0000000..e5022cb --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/dto/response/DisplayPosterFindAllResponse.kt @@ -0,0 +1,39 @@ +package site.billilge.api.backend.domain.display.dto.response + +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Schema +import site.billilge.api.backend.domain.display.entity.DisplayPoster +import java.time.LocalDateTime + +@Schema +data class DisplayPosterFindAllResponse( + @field:ArraySchema(schema = Schema(implementation = DisplayPosterDetail::class)) + val posters: List, +) { + @Schema + data class DisplayPosterDetail( + @field:Schema(description = "포스터 ID", example = "1") + val posterId: Long, + @field:Schema(description = "포스터 제목", example = "학생회 행사 포스터") + val title: String, + @field:Schema(description = "포스터 이미지 URL", example = "https://example.com/poster.png") + val imageUrl: String, + @field:Schema(description = "활성화 여부", example = "true") + val isActive: Boolean, + @field:Schema(description = "생성일시", example = "2025-03-15T10:00:00") + val createdAt: LocalDateTime, + ) { + companion object { + @JvmStatic + fun from(poster: DisplayPoster): DisplayPosterDetail { + return DisplayPosterDetail( + posterId = poster.id!!, + title = poster.title, + imageUrl = poster.imageUrl, + isActive = poster.isActive, + createdAt = poster.createdAt, + ) + } + } + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/dto/response/DisplayResponse.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/dto/response/DisplayResponse.kt new file mode 100644 index 0000000..54b7ae5 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/dto/response/DisplayResponse.kt @@ -0,0 +1,19 @@ +package site.billilge.api.backend.domain.display.dto.response + +data class DisplayResponse( + val posters: List, + val schedules: List>>, + val items: List, +) { + data class PosterDetail( + val posterId: Long, + val title: String, + val imageUrl: String, + ) + + data class DisplayItemDetail( + val itemName: String, + val count: Int, + val imageUrl: String, + ) +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/entity/DisplayCalendarSchedule.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/entity/DisplayCalendarSchedule.kt new file mode 100644 index 0000000..bf6ac59 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/entity/DisplayCalendarSchedule.kt @@ -0,0 +1,26 @@ +package site.billilge.api.backend.domain.display.entity + +import jakarta.persistence.* +import java.time.LocalDate + +@Entity +@Table(name = "display_calendar_schedule") +class DisplayCalendarSchedule( + @Column(nullable = false) + var date: LocalDate, + + @Column(nullable = false, columnDefinition = "TEXT") + var schedules: String, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null + + val scheduleList: List + get() = schedules.split("|").map { it.trim() }.filter { it.isNotEmpty() } + + fun update(date: LocalDate, schedules: List) { + this.date = date + this.schedules = schedules.joinToString("|") + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/entity/DisplayPoster.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/entity/DisplayPoster.kt new file mode 100644 index 0000000..747cfc5 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/entity/DisplayPoster.kt @@ -0,0 +1,41 @@ +package site.billilge.api.backend.domain.display.entity + +import jakarta.persistence.* +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime + +@Entity +@Table(name = "display_poster") +@EntityListeners(AuditingEntityListener::class) +class DisplayPoster( + @Column(nullable = false) + var title: String, + + @Column(name = "image_url", nullable = false) + var imageUrl: String, + + @Column(name = "is_active", nullable = false, columnDefinition = "TINYINT(1)") + var isActive: Boolean = true, + + @CreatedDate + @Column(name = "created_at", nullable = false) + val createdAt: LocalDateTime = LocalDateTime.now(), +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null + + fun update(title: String, imageUrl: String?) { + this.title = title + imageUrl?.let { this.imageUrl = it } + } + + fun activate() { + this.isActive = true + } + + fun deactivate() { + this.isActive = false + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/exception/DisplayCalendarScheduleErrorCode.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/exception/DisplayCalendarScheduleErrorCode.kt new file mode 100644 index 0000000..f7bdb1a --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/exception/DisplayCalendarScheduleErrorCode.kt @@ -0,0 +1,11 @@ +package site.billilge.api.backend.domain.display.exception + +import org.springframework.http.HttpStatus +import site.billilge.api.backend.global.exception.ErrorCode + +enum class DisplayCalendarScheduleErrorCode( + override val message: String, + override val httpStatus: HttpStatus +): ErrorCode { + SCHEDULE_NOT_FOUND("일정을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/exception/DisplayPosterErrorCode.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/exception/DisplayPosterErrorCode.kt new file mode 100644 index 0000000..61a8c5c --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/exception/DisplayPosterErrorCode.kt @@ -0,0 +1,11 @@ +package site.billilge.api.backend.domain.display.exception + +import org.springframework.http.HttpStatus +import site.billilge.api.backend.global.exception.ErrorCode + +enum class DisplayPosterErrorCode( + override val message: String, + override val httpStatus: HttpStatus +): ErrorCode { + POSTER_NOT_FOUND("포스터를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/facade/DisplayCalendarScheduleFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/facade/DisplayCalendarScheduleFacade.kt new file mode 100644 index 0000000..b15655e --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/facade/DisplayCalendarScheduleFacade.kt @@ -0,0 +1,32 @@ +package site.billilge.api.backend.domain.display.facade + +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.display.dto.request.DisplayCalendarScheduleRequest +import site.billilge.api.backend.domain.display.dto.response.DisplayCalendarScheduleFindAllResponse +import site.billilge.api.backend.domain.display.dto.response.DisplayCalendarScheduleFindAllResponse.DisplayCalendarScheduleDetail +import site.billilge.api.backend.domain.display.service.DisplayCalendarScheduleService +import java.time.LocalDate + +@Component +class DisplayCalendarScheduleFacade( + private val displayCalendarScheduleService: DisplayCalendarScheduleService, +) { + fun getSchedules(startDate: LocalDate, endDate: LocalDate): DisplayCalendarScheduleFindAllResponse { + val schedules = displayCalendarScheduleService.getSchedules(startDate, endDate) + return DisplayCalendarScheduleFindAllResponse( + schedules.map { DisplayCalendarScheduleDetail.from(it) } + ) + } + + fun addSchedule(request: DisplayCalendarScheduleRequest) { + displayCalendarScheduleService.addSchedule(request.date, request.schedules) + } + + fun updateSchedule(scheduleId: Long, request: DisplayCalendarScheduleRequest) { + displayCalendarScheduleService.updateSchedule(scheduleId, request.date, request.schedules) + } + + fun deleteSchedule(scheduleId: Long) { + displayCalendarScheduleService.deleteSchedule(scheduleId) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/facade/DisplayFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/facade/DisplayFacade.kt new file mode 100644 index 0000000..a504a55 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/facade/DisplayFacade.kt @@ -0,0 +1,53 @@ +package site.billilge.api.backend.domain.display.facade + +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.display.dto.response.DisplayResponse +import site.billilge.api.backend.domain.display.service.DisplayCalendarScheduleService +import site.billilge.api.backend.domain.display.service.DisplayPosterService +import site.billilge.api.backend.domain.item.service.ItemService +import java.time.LocalDate + +@Component +class DisplayFacade( + private val displayPosterService: DisplayPosterService, + private val displayCalendarScheduleService: DisplayCalendarScheduleService, + private val itemService: ItemService, +) { + fun getDisplay(): DisplayResponse { + val posters = displayPosterService.getActivePosters() + .map { poster -> + DisplayResponse.PosterDetail( + posterId = poster.id!!, + title = poster.title, + imageUrl = poster.imageUrl, + ) + } + + val today = LocalDate.now() + val startDate = today.minusDays(3) + val endDate = today.plusDays(3) + + val scheduleEntities = displayCalendarScheduleService.getSchedules(startDate, endDate) + val scheduleMap = scheduleEntities.associate { it.date to it.scheduleList } + + val schedules = (0L..6L).map { offset -> + val date = startDate.plusDays(offset) + mapOf(date.toString() to (scheduleMap[date] ?: emptyList())) + } + + val items = itemService.getAllItems() + .map { item -> + DisplayResponse.DisplayItemDetail( + itemName = item.name, + count = item.count, + imageUrl = item.imageUrl, + ) + } + + return DisplayResponse( + posters = posters, + schedules = schedules, + items = items, + ) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/facade/DisplayPosterFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/facade/DisplayPosterFacade.kt new file mode 100644 index 0000000..fc5f517 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/facade/DisplayPosterFacade.kt @@ -0,0 +1,53 @@ +package site.billilge.api.backend.domain.display.facade + +import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile +import site.billilge.api.backend.domain.display.dto.request.DisplayPosterRequest +import site.billilge.api.backend.domain.display.dto.response.DisplayPosterFindAllResponse +import site.billilge.api.backend.domain.display.dto.response.DisplayPosterFindAllResponse.DisplayPosterDetail +import site.billilge.api.backend.domain.display.service.DisplayPosterService +import site.billilge.api.backend.global.exception.ApiException +import site.billilge.api.backend.global.exception.GlobalErrorCode +import site.billilge.api.backend.global.external.FileStorageService +import java.util.* + +@Component +class DisplayPosterFacade( + private val displayPosterService: DisplayPosterService, + private val fileStorageService: FileStorageService, +) { + fun getAllPosters(): DisplayPosterFindAllResponse { + val posters = displayPosterService.getAllPosters() + return DisplayPosterFindAllResponse( + posters.map { DisplayPosterDetail.from(it) } + ) + } + + fun addPoster(image: MultipartFile, request: DisplayPosterRequest) { + val imageUrl = fileStorageService.uploadImageFile(image, "posters/${UUID.randomUUID()}") + ?: throw ApiException(GlobalErrorCode.IMAGE_UPLOAD_FAILED) + displayPosterService.addPoster(imageUrl, request.title) + } + + fun updatePoster(posterId: Long, image: MultipartFile?, request: DisplayPosterRequest) { + val imageUrl = if (image != null && !image.isEmpty) { + fileStorageService.uploadImageFile(image, "posters/${UUID.randomUUID()}") + ?: throw ApiException(GlobalErrorCode.IMAGE_UPLOAD_FAILED) + } else { + null + } + displayPosterService.updatePoster(posterId, imageUrl, request.title) + } + + fun deletePoster(posterId: Long) { + displayPosterService.deletePoster(posterId) + } + + fun activatePoster(posterId: Long) { + displayPosterService.activatePoster(posterId) + } + + fun deactivatePoster(posterId: Long) { + displayPosterService.deactivatePoster(posterId) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/repository/DisplayCalendarScheduleRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/repository/DisplayCalendarScheduleRepository.kt new file mode 100644 index 0000000..13e53a2 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/repository/DisplayCalendarScheduleRepository.kt @@ -0,0 +1,9 @@ +package site.billilge.api.backend.domain.display.repository + +import org.springframework.data.jpa.repository.JpaRepository +import site.billilge.api.backend.domain.display.entity.DisplayCalendarSchedule +import java.time.LocalDate + +interface DisplayCalendarScheduleRepository : JpaRepository { + fun findByDateBetween(startDate: LocalDate, endDate: LocalDate): List +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/repository/DisplayPosterRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/repository/DisplayPosterRepository.kt new file mode 100644 index 0000000..c05e99b --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/repository/DisplayPosterRepository.kt @@ -0,0 +1,8 @@ +package site.billilge.api.backend.domain.display.repository + +import org.springframework.data.jpa.repository.JpaRepository +import site.billilge.api.backend.domain.display.entity.DisplayPoster + +interface DisplayPosterRepository : JpaRepository { + fun findByIsActiveTrue(): List +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/service/DisplayCalendarScheduleService.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/service/DisplayCalendarScheduleService.kt new file mode 100644 index 0000000..62720c5 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/service/DisplayCalendarScheduleService.kt @@ -0,0 +1,44 @@ +package site.billilge.api.backend.domain.display.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import site.billilge.api.backend.domain.display.entity.DisplayCalendarSchedule +import site.billilge.api.backend.domain.display.exception.DisplayCalendarScheduleErrorCode +import site.billilge.api.backend.domain.display.repository.DisplayCalendarScheduleRepository +import site.billilge.api.backend.global.exception.ApiException +import java.time.LocalDate + +@Service +@Transactional(readOnly = true) +class DisplayCalendarScheduleService( + private val displayCalendarScheduleRepository: DisplayCalendarScheduleRepository, +) { + fun getSchedules(startDate: LocalDate, endDate: LocalDate): List { + return displayCalendarScheduleRepository.findByDateBetween(startDate, endDate) + } + + @Transactional + fun addSchedule(date: LocalDate, schedules: List) { + val schedule = DisplayCalendarSchedule( + date = date, + schedules = schedules.joinToString("|"), + ) + displayCalendarScheduleRepository.save(schedule) + } + + @Transactional + fun updateSchedule(scheduleId: Long, date: LocalDate, schedules: List) { + val schedule = displayCalendarScheduleRepository.findById(scheduleId) + .orElseThrow { ApiException(DisplayCalendarScheduleErrorCode.SCHEDULE_NOT_FOUND) } + + schedule.update(date, schedules) + } + + @Transactional + fun deleteSchedule(scheduleId: Long) { + val schedule = displayCalendarScheduleRepository.findById(scheduleId) + .orElseThrow { ApiException(DisplayCalendarScheduleErrorCode.SCHEDULE_NOT_FOUND) } + + displayCalendarScheduleRepository.delete(schedule) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/display/service/DisplayPosterService.kt b/src/main/kotlin/site/billilge/api/backend/domain/display/service/DisplayPosterService.kt new file mode 100644 index 0000000..94af149 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/display/service/DisplayPosterService.kt @@ -0,0 +1,63 @@ +package site.billilge.api.backend.domain.display.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import site.billilge.api.backend.domain.display.entity.DisplayPoster +import site.billilge.api.backend.domain.display.exception.DisplayPosterErrorCode +import site.billilge.api.backend.domain.display.repository.DisplayPosterRepository +import site.billilge.api.backend.global.exception.ApiException + +@Service +@Transactional(readOnly = true) +class DisplayPosterService( + private val displayPosterRepository: DisplayPosterRepository, +) { + fun getAllPosters(): List { + return displayPosterRepository.findAll() + } + + fun getActivePosters(): List { + return displayPosterRepository.findByIsActiveTrue() + } + + @Transactional + fun addPoster(imageUrl: String, title: String) { + val poster = DisplayPoster( + title = title, + imageUrl = imageUrl, + ) + displayPosterRepository.save(poster) + } + + @Transactional + fun updatePoster(posterId: Long, imageUrl: String?, title: String) { + val poster = displayPosterRepository.findById(posterId) + .orElseThrow { ApiException(DisplayPosterErrorCode.POSTER_NOT_FOUND) } + + poster.update(title, imageUrl) + } + + @Transactional + fun deletePoster(posterId: Long) { + val poster = displayPosterRepository.findById(posterId) + .orElseThrow { ApiException(DisplayPosterErrorCode.POSTER_NOT_FOUND) } + + displayPosterRepository.delete(poster) + } + + @Transactional + fun activatePoster(posterId: Long) { + val poster = displayPosterRepository.findById(posterId) + .orElseThrow { ApiException(DisplayPosterErrorCode.POSTER_NOT_FOUND) } + + poster.activate() + } + + @Transactional + fun deactivatePoster(posterId: Long) { + val poster = displayPosterRepository.findById(posterId) + .orElseThrow { ApiException(DisplayPosterErrorCode.POSTER_NOT_FOUND) } + + poster.deactivate() + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/controller/AdminItemApi.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/controller/AdminItemApi.kt index b93b36a..68d0255 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/item/controller/AdminItemApi.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/controller/AdminItemApi.kt @@ -17,6 +17,7 @@ import org.springframework.web.multipart.MultipartFile import site.billilge.api.backend.domain.item.dto.request.ItemRequest import site.billilge.api.backend.domain.item.dto.response.AdminItemFindAllResponse import site.billilge.api.backend.domain.item.dto.response.ItemDetail +import site.billilge.api.backend.domain.item.dto.response.ItemFindAllResponse import site.billilge.api.backend.global.dto.PageableCondition import site.billilge.api.backend.global.dto.SearchCondition import site.billilge.api.backend.global.exception.ErrorResponse @@ -130,4 +131,20 @@ interface AdminItemApi { fun getItemById( @PathVariable itemId: Long, ): ResponseEntity + + @Operation( + summary = "물품 이름 검색", + description = "물품 이름으로 물품을 검색하는 관리자용 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "물품 검색 성공" + ) + ] + ) + fun searchItems( + @ModelAttribute searchCondition: SearchCondition + ): ResponseEntity } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/controller/AdminItemController.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/controller/AdminItemController.kt index aed71c0..ac774e8 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/item/controller/AdminItemController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/controller/AdminItemController.kt @@ -8,16 +8,18 @@ import org.springframework.web.multipart.MultipartFile import site.billilge.api.backend.domain.item.dto.request.ItemRequest import site.billilge.api.backend.domain.item.dto.response.AdminItemFindAllResponse import site.billilge.api.backend.domain.item.dto.response.ItemDetail -import site.billilge.api.backend.domain.item.service.ItemService +import site.billilge.api.backend.domain.item.dto.response.ItemFindAllResponse +import site.billilge.api.backend.domain.item.facade.AdminItemFacade +import site.billilge.api.backend.domain.member.enums.Role import site.billilge.api.backend.global.annotation.OnlyAdmin import site.billilge.api.backend.global.dto.PageableCondition import site.billilge.api.backend.global.dto.SearchCondition @RestController @RequestMapping("/admin/items") -@OnlyAdmin +@OnlyAdmin(roles = [Role.ADMIN, Role.GA, Role.WORKER]) class AdminItemController( - private val itemService: ItemService + private val adminItemFacade: AdminItemFacade ) : AdminItemApi { @GetMapping override fun getAllAdminItems( @@ -25,29 +27,31 @@ class AdminItemController( @ModelAttribute searchCondition: SearchCondition ): ResponseEntity { return ResponseEntity.ok( - itemService.getAllAdminItems( + adminItemFacade.getAllAdminItems( pageableCondition, searchCondition ) ) } + @OnlyAdmin @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) override fun addItem( @RequestPart image: MultipartFile, @RequestPart itemRequest: ItemRequest ): ResponseEntity { - itemService.addItem(image, itemRequest) + adminItemFacade.addItem(image, itemRequest) return ResponseEntity.status(HttpStatus.CREATED).build() } + @OnlyAdmin @PutMapping("/{itemId}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) override fun updateItem( @PathVariable itemId: Long, @RequestPart image: MultipartFile?, @RequestPart itemRequest: ItemRequest ): ResponseEntity { - itemService.updateItem(image, itemId, itemRequest) + adminItemFacade.updateItem(image, itemId, itemRequest) return ResponseEntity.ok().build() } @@ -55,12 +59,20 @@ class AdminItemController( override fun getItemById( @PathVariable itemId: Long ): ResponseEntity { - return ResponseEntity.ok(itemService.getItemById(itemId)) + return ResponseEntity.ok(adminItemFacade.getItemById(itemId)) } + @OnlyAdmin @DeleteMapping("/{itemId}") override fun deleteItem(@PathVariable itemId: Long): ResponseEntity { - itemService.deleteItem(itemId) + adminItemFacade.deleteItem(itemId) return ResponseEntity.noContent().build() } + + @GetMapping("/search") + override fun searchItems( + @ModelAttribute searchCondition: SearchCondition + ): ResponseEntity { + return ResponseEntity.ok(adminItemFacade.searchItems(searchCondition)) + } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/controller/ItemController.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/controller/ItemController.kt index 260ab12..c9a4cf0 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/item/controller/ItemController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/controller/ItemController.kt @@ -6,19 +6,19 @@ import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import site.billilge.api.backend.domain.item.dto.response.ItemFindAllResponse -import site.billilge.api.backend.domain.item.service.ItemService +import site.billilge.api.backend.domain.item.facade.ItemFacade import site.billilge.api.backend.global.dto.SearchCondition @RestController @RequestMapping("/items") class ItemController( - private val itemService: ItemService + private val itemFacade: ItemFacade ) : ItemApi { @GetMapping override fun getItems( @ModelAttribute searchCondition: SearchCondition ): ResponseEntity { - val response = itemService.searchItems(searchCondition) + val response = itemFacade.searchItems(searchCondition) return ResponseEntity.ok(response) } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/facade/AdminItemFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/facade/AdminItemFacade.kt new file mode 100644 index 0000000..c3ed82b --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/facade/AdminItemFacade.kt @@ -0,0 +1,54 @@ +package site.billilge.api.backend.domain.item.facade + +import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile +import site.billilge.api.backend.domain.item.dto.request.ItemRequest +import site.billilge.api.backend.domain.item.dto.response.AdminItemDetail +import site.billilge.api.backend.domain.item.dto.response.AdminItemFindAllResponse +import site.billilge.api.backend.domain.item.dto.response.ItemDetail +import site.billilge.api.backend.domain.item.dto.response.ItemFindAllResponse +import site.billilge.api.backend.domain.item.service.ItemService +import site.billilge.api.backend.global.dto.PageableCondition +import site.billilge.api.backend.global.dto.SearchCondition + +@Component +class AdminItemFacade( + private val itemService: ItemService, +) { + fun getAllAdminItems(pageableCondition: PageableCondition, searchCondition: SearchCondition): AdminItemFindAllResponse { + val page = itemService.getAllAdminItems(pageableCondition, searchCondition) + val adminItemDetails = page.content.map { + AdminItemDetail( + itemId = it.itemId, + itemName = it.itemName, + itemType = it.itemType, + count = it.count, + renterCount = it.renterCount, + imageUrl = it.imageUrl, + ) + } + return AdminItemFindAllResponse(adminItemDetails, page.totalPages) + } + + fun addItem(image: MultipartFile, itemRequest: ItemRequest) { + itemService.addItem(image, itemRequest.name, itemRequest.type, itemRequest.count) + } + + fun updateItem(image: MultipartFile?, itemId: Long, itemRequest: ItemRequest) { + itemService.updateItem(image, itemId, itemRequest.name, itemRequest.type, itemRequest.count) + } + + fun getItemById(itemId: Long): ItemDetail { + val item = itemService.getItemById(itemId) + return ItemDetail.from(item) + } + + fun searchItems(searchCondition: SearchCondition): ItemFindAllResponse { + val items = itemService.searchItems(searchCondition) + return ItemFindAllResponse(items.map { ItemDetail.from(it) }) + } + + fun deleteItem(itemId: Long): Boolean { + return itemService.deleteItem(itemId) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/facade/ItemFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/facade/ItemFacade.kt new file mode 100644 index 0000000..b6f7184 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/facade/ItemFacade.kt @@ -0,0 +1,17 @@ +package site.billilge.api.backend.domain.item.facade + +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.item.dto.response.ItemDetail +import site.billilge.api.backend.domain.item.dto.response.ItemFindAllResponse +import site.billilge.api.backend.domain.item.service.ItemService +import site.billilge.api.backend.global.dto.SearchCondition + +@Component +class ItemFacade( + private val itemService: ItemService, +) { + fun searchItems(searchCondition: SearchCondition): ItemFindAllResponse { + val items = itemService.searchItems(searchCondition) + return ItemFindAllResponse(items.map { ItemDetail.from(it) }) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepositoryCustom.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepositoryCustom.kt index b8f385a..b693a03 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepositoryCustom.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepositoryCustom.kt @@ -2,8 +2,8 @@ package site.billilge.api.backend.domain.item.repository import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable -import site.billilge.api.backend.domain.item.dto.response.AdminItemDetail +import site.billilge.api.backend.domain.item.repository.dto.ItemWithRentCountQueryResult interface ItemRepositoryCustom { - fun findAllAsAdminItemDetailByKeyword(keyword: String, pageable: Pageable): Page -} \ No newline at end of file + fun findAllAsAdminItemDetailByKeyword(keyword: String, pageable: Pageable): Page +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepositoryCustomImpl.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepositoryCustomImpl.kt index bc22a0f..0b25afd 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepositoryCustomImpl.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepositoryCustomImpl.kt @@ -8,21 +8,21 @@ import com.querydsl.jpa.impl.JPAQueryFactory import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.support.PageableExecutionUtils -import site.billilge.api.backend.domain.item.dto.response.AdminItemDetail +import site.billilge.api.backend.domain.item.repository.dto.ItemWithRentCountQueryResult import site.billilge.api.backend.domain.item.entity.QItem import site.billilge.api.backend.domain.rental.entity.QRentalHistory class ItemRepositoryCustomImpl( private val queryFactory: JPAQueryFactory ) : ItemRepositoryCustom { - override fun findAllAsAdminItemDetailByKeyword(keyword: String, pageable: Pageable): Page { + override fun findAllAsAdminItemDetailByKeyword(keyword: String, pageable: Pageable): Page { val item = QItem.item val rentalHistory = QRentalHistory.rentalHistory val contents = queryFactory .select( Projections.constructor( - AdminItemDetail::class.java, + ItemWithRentCountQueryResult::class.java, item.id.`as`("itemId"), item.name.`as`("itemName"), item.type.`as`("itemType"), @@ -54,4 +54,4 @@ class ItemRepositoryCustomImpl( private fun searchCondition(item: QItem, keyword: String): BooleanExpression { return item.name.like("%${keyword}%") } -} \ No newline at end of file +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/repository/dto/ItemWithRentCountQueryResult.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/repository/dto/ItemWithRentCountQueryResult.kt new file mode 100644 index 0000000..47bcb33 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/repository/dto/ItemWithRentCountQueryResult.kt @@ -0,0 +1,12 @@ +package site.billilge.api.backend.domain.item.repository.dto + +import site.billilge.api.backend.domain.item.enums.ItemType + +data class ItemWithRentCountQueryResult( + val itemId: Long, + val itemName: String, + val itemType: ItemType, + val count: Int, + val renterCount: Long, + val imageUrl: String, +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/service/ItemService.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/service/ItemService.kt index 3413ccf..75dc971 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/item/service/ItemService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/service/ItemService.kt @@ -1,65 +1,58 @@ package site.billilge.api.backend.domain.item.service +import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.multipart.MultipartFile -import site.billilge.api.backend.domain.item.dto.request.ItemRequest -import site.billilge.api.backend.domain.item.dto.response.AdminItemFindAllResponse -import site.billilge.api.backend.domain.item.dto.response.ItemDetail -import site.billilge.api.backend.domain.item.dto.response.ItemFindAllResponse import site.billilge.api.backend.domain.item.entity.Item +import site.billilge.api.backend.domain.item.enums.ItemType import site.billilge.api.backend.domain.item.exception.ItemErrorCode import site.billilge.api.backend.domain.item.repository.ItemRepository +import site.billilge.api.backend.domain.item.repository.dto.ItemWithRentCountQueryResult import site.billilge.api.backend.global.dto.PageableCondition import site.billilge.api.backend.global.dto.SearchCondition import site.billilge.api.backend.global.exception.ApiException import site.billilge.api.backend.global.exception.GlobalErrorCode -import site.billilge.api.backend.global.external.s3.S3Service +import site.billilge.api.backend.global.external.FileStorageService @Service @Transactional(readOnly = true) class ItemService( private val itemRepository: ItemRepository, - private val s3Service: S3Service, + private val fileStorageService: FileStorageService, ) { - fun getAllItems(): ItemFindAllResponse { - val itemDetails = itemRepository.findAll() - .map { ItemDetail.from(it) } - - return ItemFindAllResponse(itemDetails) + fun getAllItems(): List { + return itemRepository.findAll() } fun getAllAdminItems( - @ModelAttribute pageableCondition: PageableCondition, - @ModelAttribute searchCondition: SearchCondition, - ): AdminItemFindAllResponse { + pageableCondition: PageableCondition, + searchCondition: SearchCondition, + ): Page { val pageRequest = PageRequest.of( pageableCondition.pageNo, pageableCondition.size, Sort.by(Sort.Direction.ASC, "name") ) - val adminItemDetails = itemRepository.findAllAsAdminItemDetailByKeyword(searchCondition.search, pageRequest) - - return AdminItemFindAllResponse(adminItemDetails.content, adminItemDetails.totalPages) + return itemRepository.findAllAsAdminItemDetailByKeyword(searchCondition.search, pageRequest) } @Transactional - fun addItem(image: MultipartFile, itemRequest: ItemRequest) { - if (itemRepository.existsByName(itemRequest.name)) + fun addItem(image: MultipartFile, name: String, type: ItemType, count: Int) { + if (itemRepository.existsByName(name)) throw ApiException(ItemErrorCode.ITEM_NAME_ALREADY_EXISTS) checkImageIsSvg(image) - val imageUrl = s3Service.uploadImageFile(image) + val imageUrl = fileStorageService.uploadImageFile(image) ?: throw ApiException(GlobalErrorCode.IMAGE_UPLOAD_FAILED) val newItem = Item( - name = itemRequest.name, - type = itemRequest.type, - count = itemRequest.count, + name = name, + type = type, + count = count, imageUrl = imageUrl, ) @@ -67,7 +60,7 @@ class ItemService( } @Transactional - fun updateItem(image: MultipartFile?, itemId: Long, itemRequest: ItemRequest) { + fun updateItem(image: MultipartFile?, itemId: Long, name: String, type: ItemType, count: Int) { val item = itemRepository.findById(itemId) .orElseThrow { ApiException(ItemErrorCode.ITEM_NOT_FOUND) } @@ -77,14 +70,14 @@ class ItemService( imageUrl = item.imageUrl } else { checkImageIsSvg(image) - imageUrl = s3Service.uploadImageFile(image) + imageUrl = fileStorageService.uploadImageFile(image) ?: throw ApiException(GlobalErrorCode.IMAGE_UPLOAD_FAILED) } item.update( - name = itemRequest.name, - type = itemRequest.type, - count = itemRequest.count, + name = name, + type = type, + count = count, imageUrl = imageUrl, ) } @@ -106,7 +99,7 @@ class ItemService( } if (isEntityDeleted) { - s3Service.deleteImageFile(imageUrl) + fileStorageService.deleteImageFile(imageUrl) return true } @@ -118,15 +111,12 @@ class ItemService( throw ApiException(ItemErrorCode.IMAGE_IS_NOT_SVG) } - fun searchItems(searchCondition: SearchCondition): ItemFindAllResponse { - val items = itemRepository.findByItemName(searchCondition.search) - - return ItemFindAllResponse(items.map { ItemDetail.from(it) }) + fun searchItems(searchCondition: SearchCondition): List { + return itemRepository.findByItemName(searchCondition.search) } - fun getItemById(itemId: Long): ItemDetail { - val item = itemRepository.findById(itemId) + fun getItemById(itemId: Long): Item { + return itemRepository.findById(itemId) .orElseThrow { ApiException(ItemErrorCode.ITEM_NOT_FOUND) } - return ItemDetail.from(item); } -} \ No newline at end of file +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AdminMemberApi.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AdminMemberApi.kt index 6c29dc2..a32356c 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AdminMemberApi.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AdminMemberApi.kt @@ -5,9 +5,14 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import site.billilge.api.backend.domain.member.dto.request.AdminRequest +import site.billilge.api.backend.domain.member.dto.request.AdminRoleUpdateRequest +import site.billilge.api.backend.global.exception.ErrorResponse import site.billilge.api.backend.domain.member.dto.response.AdminFindAllResponse import site.billilge.api.backend.domain.member.dto.response.MemberFindAllResponse import site.billilge.api.backend.global.dto.PageableCondition @@ -49,6 +54,28 @@ interface AdminMemberApi { @ModelAttribute searchCondition: SearchCondition ): ResponseEntity + @Operation( + summary = "관리자 역할 변경", + description = "관리자의 역할을 변경하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "관리자 역할 변경 성공" + ), + ApiResponse( + responseCode = "404", + description = "회원을 찾을 수 없습니다.", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + fun updateAdminRole( + @PathVariable memberId: Long, + @RequestBody request: AdminRoleUpdateRequest + ): ResponseEntity + @Operation( summary = "관리자 추가", description = "회원을 관리자에 추가하는 API" diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AdminMemberController.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AdminMemberController.kt index 6dc8224..52b6c3e 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AdminMemberController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AdminMemberController.kt @@ -3,25 +3,27 @@ package site.billilge.api.backend.domain.member.controller import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import site.billilge.api.backend.domain.member.dto.request.AdminRequest +import site.billilge.api.backend.domain.member.dto.request.AdminRoleUpdateRequest import site.billilge.api.backend.domain.member.dto.response.AdminFindAllResponse import site.billilge.api.backend.domain.member.dto.response.MemberFindAllResponse -import site.billilge.api.backend.domain.member.service.MemberService +import site.billilge.api.backend.domain.member.facade.AdminMemberFacade +import site.billilge.api.backend.domain.member.enums.Role import site.billilge.api.backend.global.annotation.OnlyAdmin import site.billilge.api.backend.global.dto.PageableCondition import site.billilge.api.backend.global.dto.SearchCondition @RestController @RequestMapping("admin/members") -@OnlyAdmin +@OnlyAdmin(roles = [Role.ADMIN, Role.GA, Role.WORKER]) class AdminMemberController( - private val memberService: MemberService + private val adminMemberFacade: AdminMemberFacade ) : AdminMemberApi { @GetMapping override fun getAllMembers( @ModelAttribute pageableCondition: PageableCondition, @ModelAttribute searchCondition: SearchCondition ): ResponseEntity { - return ResponseEntity.ok(memberService.getAllMembers(pageableCondition, searchCondition)) + return ResponseEntity.ok(adminMemberFacade.getAllMembers(pageableCondition, searchCondition)) } @GetMapping("/admins") @@ -29,18 +31,30 @@ class AdminMemberController( @ModelAttribute pageableCondition: PageableCondition, @ModelAttribute searchCondition: SearchCondition ): ResponseEntity { - return ResponseEntity.ok(memberService.getAdminList(pageableCondition, searchCondition)) + return ResponseEntity.ok(adminMemberFacade.getAdminList(pageableCondition, searchCondition)) } + @OnlyAdmin + @PatchMapping("/{memberId}/admins") + override fun updateAdminRole( + @PathVariable memberId: Long, + @RequestBody request: AdminRoleUpdateRequest + ): ResponseEntity { + adminMemberFacade.updateAdminRole(memberId, request) + return ResponseEntity.ok().build() + } + + @OnlyAdmin @PostMapping("/admins") override fun addAdmins(@RequestBody request: AdminRequest): ResponseEntity { - memberService.addAdmins(request) + adminMemberFacade.addAdmins(request) return ResponseEntity.ok().build() } + @OnlyAdmin @DeleteMapping("/admins") override fun deleteAdmins(@RequestBody request: AdminRequest): ResponseEntity { - memberService.deleteAdmins(request) + adminMemberFacade.deleteAdmins(request) return ResponseEntity.noContent().build() } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AuthController.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AuthController.kt index 7f332a3..b10e7c9 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AuthController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/AuthController.kt @@ -11,21 +11,21 @@ import site.billilge.api.backend.domain.member.dto.request.AdminLoginRequest import site.billilge.api.backend.domain.member.dto.request.SignUpRequest import site.billilge.api.backend.domain.member.dto.response.AdminLoginResponse import site.billilge.api.backend.domain.member.dto.response.SignUpResponse -import site.billilge.api.backend.domain.member.service.MemberService +import site.billilge.api.backend.domain.member.facade.AuthFacade @RestController @RequestMapping class AuthController( - private val memberService: MemberService + private val authFacade: AuthFacade ) : AuthApi { @PostMapping("/auth/sign-up") override fun signUp(@RequestBody request: SignUpRequest): ResponseEntity { - return ResponseEntity.ok(memberService.signUp(request)) + return ResponseEntity.ok(authFacade.signUp(request)) } @PostMapping("/auth/admin-login") override fun loginAdmin(@RequestBody request: AdminLoginRequest): ResponseEntity { - return ResponseEntity.ok(memberService.loginAdmin(request)) + return ResponseEntity.ok(authFacade.loginAdmin(request)) } @GetMapping("/login/oauth2/code/google") diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/MemberController.kt index 119bb00..87c420c 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/MemberController.kt @@ -7,20 +7,20 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import site.billilge.api.backend.domain.member.dto.request.MemberFCMTokenRequest -import site.billilge.api.backend.domain.member.service.MemberService +import site.billilge.api.backend.domain.member.facade.MemberFacade import site.billilge.api.backend.global.security.oauth2.UserAuthInfo @RestController @RequestMapping("/members") class MemberController( - private val memberService: MemberService, + private val memberFacade: MemberFacade, ) : MemberApi { @PostMapping("/me/fcm-token") override fun setFCMToken( @AuthenticationPrincipal userAuthInfo: UserAuthInfo, @RequestBody request: MemberFCMTokenRequest ): ResponseEntity { - memberService.setMemberFCMToken(userAuthInfo.memberId, request) + memberFacade.setMemberFCMToken(userAuthInfo.memberId, request) return ResponseEntity.ok().build() } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/dto/request/AdminRequest.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/dto/request/AdminRequest.kt index 3c14488..ec94a1d 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/dto/request/AdminRequest.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/dto/request/AdminRequest.kt @@ -1,9 +1,12 @@ package site.billilge.api.backend.domain.member.dto.request import io.swagger.v3.oas.annotations.media.Schema +import site.billilge.api.backend.domain.member.enums.Role @Schema data class AdminRequest( @field:Schema(description = "회원 ID 목록", example = "[1, 2, 3]") - val memberIds: List + val memberIds: List, + @field:Schema(description = "관리자 역할", example = "ADMIN") + val role: Role = Role.ADMIN, ) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/dto/request/AdminRoleUpdateRequest.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/dto/request/AdminRoleUpdateRequest.kt new file mode 100644 index 0000000..57691c5 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/dto/request/AdminRoleUpdateRequest.kt @@ -0,0 +1,10 @@ +package site.billilge.api.backend.domain.member.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import site.billilge.api.backend.domain.member.enums.Role + +@Schema +data class AdminRoleUpdateRequest( + @field:Schema(description = "변경할 역할", example = "GA") + val role: Role, +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/dto/response/AdminMemberDetail.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/dto/response/AdminMemberDetail.kt index 71fd058..02072c0 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/dto/response/AdminMemberDetail.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/dto/response/AdminMemberDetail.kt @@ -2,6 +2,7 @@ package site.billilge.api.backend.domain.member.dto.response import io.swagger.v3.oas.annotations.media.Schema import site.billilge.api.backend.domain.member.entity.Member +import site.billilge.api.backend.domain.member.enums.Role @Schema data class AdminMemberDetail( @@ -10,7 +11,9 @@ data class AdminMemberDetail( @field:Schema(description = "관리자 이름", example = "황수민") val name: String, @field:Schema(description = "관리자 학번", example = "20211234") - val studentId: String + val studentId: String, + @field:Schema(description = "관리자 역할", example = "ADMIN") + val role: Role ) { companion object { @JvmStatic @@ -18,7 +21,8 @@ data class AdminMemberDetail( return AdminMemberDetail( memberId = member.id!!, name = member.name, - studentId = member.studentId + studentId = member.studentId, + role = member.role ) } } diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/enums/Role.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/enums/Role.kt index 3ac009e..b96f8f4 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/enums/Role.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/enums/Role.kt @@ -5,5 +5,7 @@ enum class Role( val description: String, ) { USER("ROLE_USER", "사용자"), - ADMIN("ROLE_ADMIN", "관리자") + ADMIN("ROLE_ADMIN", "관리자"), + WORKER("ROLE_WORKER", "근무자"), + GA("ROLE_GA", "총무부"), } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/facade/AdminMemberFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/facade/AdminMemberFacade.kt new file mode 100644 index 0000000..be5dcd9 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/facade/AdminMemberFacade.kt @@ -0,0 +1,41 @@ +package site.billilge.api.backend.domain.member.facade + +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.member.dto.request.AdminRequest +import site.billilge.api.backend.domain.member.dto.request.AdminRoleUpdateRequest +import site.billilge.api.backend.domain.member.dto.response.AdminFindAllResponse +import site.billilge.api.backend.domain.member.dto.response.AdminMemberDetail +import site.billilge.api.backend.domain.member.dto.response.MemberDetail +import site.billilge.api.backend.domain.member.dto.response.MemberFindAllResponse +import site.billilge.api.backend.domain.member.service.MemberService +import site.billilge.api.backend.global.dto.PageableCondition +import site.billilge.api.backend.global.dto.SearchCondition + +@Component +class AdminMemberFacade( + private val memberService: MemberService, +) { + fun getAllMembers(pageableCondition: PageableCondition, searchCondition: SearchCondition): MemberFindAllResponse { + val members = memberService.getAllMembers(pageableCondition, searchCondition) + val memberDetails = members.map { MemberDetail.from(it) }.toList() + return MemberFindAllResponse(memberDetails, members.totalPages) + } + + fun getAdminList(pageableCondition: PageableCondition, searchCondition: SearchCondition): AdminFindAllResponse { + val adminList = memberService.getAdminList(pageableCondition, searchCondition) + val adminDetails = adminList.map { AdminMemberDetail.from(it) }.toList() + return AdminFindAllResponse(adminDetails, adminList.totalPages) + } + + fun updateAdminRole(memberId: Long, request: AdminRoleUpdateRequest) { + memberService.updateAdminRole(memberId, request.role) + } + + fun addAdmins(request: AdminRequest) { + memberService.addAdmins(request.memberIds, request.role) + } + + fun deleteAdmins(request: AdminRequest) { + memberService.deleteAdmins(request.memberIds) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/facade/AuthFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/facade/AuthFacade.kt new file mode 100644 index 0000000..b0b1643 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/facade/AuthFacade.kt @@ -0,0 +1,23 @@ +package site.billilge.api.backend.domain.member.facade + +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.member.dto.request.AdminLoginRequest +import site.billilge.api.backend.domain.member.dto.request.SignUpRequest +import site.billilge.api.backend.domain.member.dto.response.AdminLoginResponse +import site.billilge.api.backend.domain.member.dto.response.SignUpResponse +import site.billilge.api.backend.domain.member.service.MemberService + +@Component +class AuthFacade( + private val memberService: MemberService, +) { + fun signUp(request: SignUpRequest): SignUpResponse { + val accessToken = memberService.signUp(request.email, request.studentId, request.name) + return SignUpResponse(accessToken) + } + + fun loginAdmin(request: AdminLoginRequest): AdminLoginResponse { + val accessToken = memberService.loginAdmin(request.studentId, request.password) + return AdminLoginResponse(accessToken) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/facade/MemberFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/facade/MemberFacade.kt new file mode 100644 index 0000000..4151cd6 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/facade/MemberFacade.kt @@ -0,0 +1,14 @@ +package site.billilge.api.backend.domain.member.facade + +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.member.dto.request.MemberFCMTokenRequest +import site.billilge.api.backend.domain.member.service.MemberService + +@Component +class MemberFacade( + private val memberService: MemberService, +) { + fun setMemberFCMToken(memberId: Long?, request: MemberFCMTokenRequest) { + memberService.setMemberFCMToken(memberId, request.token) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/repository/MemberRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/repository/MemberRepository.kt index ec38c9f..293d29d 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/repository/MemberRepository.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/repository/MemberRepository.kt @@ -20,6 +20,9 @@ interface MemberRepository: JpaRepository { @Query("SELECT m FROM Member m WHERE m.role = :role AND m.name LIKE CONCAT('%', :name, '%')") fun findAllByRoleAndNameContaining(@Param("role") role: Role, @Param("name") name: String, pageable: Pageable): Page + @Query("SELECT m FROM Member m WHERE m.role IN :roles AND m.name LIKE CONCAT('%', :name, '%')") + fun findAllByRoleInAndNameContaining(@Param("roles") roles: List, @Param("name") name: String, pageable: Pageable): Page + @Query("SELECT m FROM Member m WHERE m.name LIKE CONCAT('%', :name, '%')") fun findAllByNameContaining(@Param("name") name: String, pageable: Pageable): Page diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/service/MemberService.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/service/MemberService.kt index c70548f..6103687 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/service/MemberService.kt @@ -1,16 +1,13 @@ package site.billilge.api.backend.domain.member.service +import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import org.springframework.beans.factory.annotation.Value -import site.billilge.api.backend.domain.member.dto.request.AdminLoginRequest -import site.billilge.api.backend.domain.member.dto.request.AdminRequest -import site.billilge.api.backend.domain.member.dto.request.MemberFCMTokenRequest -import site.billilge.api.backend.domain.member.dto.request.SignUpRequest -import site.billilge.api.backend.domain.member.dto.response.* +import site.billilge.api.backend.domain.configvalue.enums.ConfigValueKeys +import site.billilge.api.backend.domain.configvalue.service.ConfigValueService import site.billilge.api.backend.domain.member.exception.MemberErrorCode import site.billilge.api.backend.domain.member.repository.MemberRepository import site.billilge.api.backend.global.exception.ApiException @@ -28,29 +25,21 @@ class MemberService( private val memberRepository: MemberRepository, private val tokenProvider: TokenProvider, private val payerService: PayerService, - @Value("\${login.admin-password}") - private val adminPassword: String, + private val configValueService: ConfigValueService, ) { @Transactional - fun signUp(request: SignUpRequest): SignUpResponse { - val email = request.email - + fun signUp(email: String, studentId: String, name: String): String { if (memberRepository.existsByEmail(email)) { throw ApiException(MemberErrorCode.EMAIL_ALREADY_EXISTS) } - val name = request.name - val studentId = request.studentId - if (memberRepository.existsByStudentIdAndName(studentId, name)) { val member = memberRepository.findByStudentId(studentId) member?.let { it.updateEmail(email) - return SignUpResponse( - tokenProvider.generateToken(it, Duration.ofDays(30)) - ) + return tokenProvider.generateToken(it, Duration.ofDays(30)) } } @@ -67,37 +56,38 @@ class MemberService( memberRepository.save(member) payerService.updatePayerInfo(member) - val accessToken = tokenProvider.generateToken(member, Duration.ofDays(30)) - - return SignUpResponse(accessToken) + return tokenProvider.generateToken(member, Duration.ofDays(30)) } - fun getAdminList(pageableCondition: PageableCondition, searchCondition: SearchCondition): AdminFindAllResponse { + fun getAdminList(pageableCondition: PageableCondition, searchCondition: SearchCondition): Page { val pageRequest = PageRequest.of( pageableCondition.pageNo, pageableCondition.size, Sort.by(Sort.Direction.ASC, "studentId") ) - val adminList = memberRepository.findAllByRoleAndNameContaining(Role.ADMIN, searchCondition.search, pageRequest) - val totalPage = adminList.totalPages - val adminDetails = adminList - .map { AdminMemberDetail.from(it) } - .toList() + val adminRoles = listOf(Role.ADMIN, Role.WORKER, Role.GA) + return memberRepository.findAllByRoleInAndNameContaining(adminRoles, searchCondition.search, pageRequest) + } - return AdminFindAllResponse(adminDetails, totalPage) + @Transactional + fun updateAdminRole(memberId: Long, role: Role) { + val member = memberRepository.findByIdOrNull(memberId) + ?: throw ApiException(MemberErrorCode.MEMBER_NOT_FOUND) + + member.updateRole(role) } @Transactional - fun addAdmins(request: AdminRequest) { - memberRepository.findAllByIds(request.memberIds) + fun addAdmins(memberIds: List, role: Role) { + memberRepository.findAllByIds(memberIds) .forEach { member -> - member.updateRole(Role.ADMIN) + member.updateRole(role) } } @Transactional - fun deleteAdmins(request: AdminRequest) { - memberRepository.findAllByIds(request.memberIds) + fun deleteAdmins(memberIds: List) { + memberRepository.findAllByIds(memberIds) .forEach { member -> member.updateRole(Role.USER) } @@ -106,45 +96,48 @@ class MemberService( fun getAllMembers( pageableCondition: PageableCondition, searchCondition: SearchCondition - ): MemberFindAllResponse { + ): Page { val pageRequest = PageRequest.of( pageableCondition.pageNo, pageableCondition.size, Sort.by(Sort.Direction.ASC, "studentId") ) - val members = if (searchCondition.search.isEmpty()) { + return if (searchCondition.search.isEmpty()) { memberRepository.findAll(pageRequest) } else { memberRepository.findAllByNameContaining(searchCondition.search, pageRequest) } - - val memberDetails = members - .map { MemberDetail.from(it) } - - return MemberFindAllResponse(memberDetails.toList(), members.totalPages) } @Transactional - fun setMemberFCMToken(memberId: Long?, request: MemberFCMTokenRequest) { + fun setMemberFCMToken(memberId: Long?, token: String) { val member = memberRepository.findByIdOrNull(memberId!!) ?: throw ApiException(MemberErrorCode.MEMBER_NOT_FOUND) - member.updateFCMToken(request.token) + member.updateFCMToken(token) + } + + fun findById(memberId: Long): Member { + return memberRepository.findByIdOrNull(memberId) + ?: throw ApiException(MemberErrorCode.MEMBER_NOT_FOUND) + } + + fun findAllByRole(role: Role): List { + return memberRepository.findAllByRole(role) } - fun loginAdmin(request: AdminLoginRequest): AdminLoginResponse { - val member = memberRepository.findByStudentId(request.studentId) + fun loginAdmin(studentId: String, password: String): String { + val member = memberRepository.findByStudentId(studentId) ?: throw ApiException(MemberErrorCode.MEMBER_NOT_FOUND) - if (request.password != adminPassword) + if (password != configValueService.getValueByKey(ConfigValueKeys.ADMIN_PASSWORD.key)) throw ApiException(MemberErrorCode.ADMIN_PASSWORD_MISMATCH) - if (member.role != Role.ADMIN) + if (member.role !in listOf(Role.ADMIN, Role.GA, Role.WORKER)) throw ApiException(MemberErrorCode.FORBIDDEN) - val accessToken = tokenProvider.generateToken(member, Duration.ofDays(30)) - - return AdminLoginResponse(accessToken) + return tokenProvider.generateToken(member, Duration.ofDays(30)) } -} \ No newline at end of file + +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/AdminNotificationController.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/AdminNotificationController.kt index d6936d9..1b52779 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/AdminNotificationController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/AdminNotificationController.kt @@ -6,18 +6,19 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import site.billilge.api.backend.domain.notification.dto.response.NotificationFindAllResponse -import site.billilge.api.backend.domain.notification.service.NotificationService +import site.billilge.api.backend.domain.notification.facade.AdminNotificationFacade +import site.billilge.api.backend.domain.member.enums.Role import site.billilge.api.backend.global.annotation.OnlyAdmin import site.billilge.api.backend.global.security.oauth2.UserAuthInfo -@OnlyAdmin +@OnlyAdmin(roles = [Role.ADMIN, Role.GA, Role.WORKER]) @RestController @RequestMapping("/admin/notifications") class AdminNotificationController( - private val notificationService: NotificationService + private val adminNotificationFacade: AdminNotificationFacade ) : AdminNotificationApi { @GetMapping override fun getAdminNotifications(@AuthenticationPrincipal userAuthInfo: UserAuthInfo): ResponseEntity { - return ResponseEntity.ok(notificationService.getAdminNotifications(userAuthInfo.memberId)) + return ResponseEntity.ok(adminNotificationFacade.getAdminNotifications(userAuthInfo.memberId)) } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/NotificationController.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/NotificationController.kt index 6b1074d..e5f4fb5 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/NotificationController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/NotificationController.kt @@ -5,13 +5,13 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import site.billilge.api.backend.domain.notification.dto.response.NotificationCountResponse import site.billilge.api.backend.domain.notification.dto.response.NotificationFindAllResponse -import site.billilge.api.backend.domain.notification.service.NotificationService +import site.billilge.api.backend.domain.notification.facade.NotificationFacade import site.billilge.api.backend.global.security.oauth2.UserAuthInfo @RestController @RequestMapping("/notifications") class NotificationController ( - private val notificationService: NotificationService + private val notificationFacade: NotificationFacade ): NotificationApi { @GetMapping @@ -19,7 +19,7 @@ class NotificationController ( @AuthenticationPrincipal userAuthInfo: UserAuthInfo ): ResponseEntity { val memberId = userAuthInfo.memberId - return ResponseEntity.ok(notificationService.getNotifications(memberId)) + return ResponseEntity.ok(notificationFacade.getNotifications(memberId)) } @GetMapping("/count") @@ -27,7 +27,7 @@ class NotificationController ( @AuthenticationPrincipal userAuthInfo: UserAuthInfo ): ResponseEntity { val memberId = userAuthInfo.memberId - return ResponseEntity.ok(notificationService.getNotificationCount(memberId)) + return ResponseEntity.ok(notificationFacade.getNotificationCount(memberId)) } @PatchMapping("/{notificationId}") @@ -36,7 +36,7 @@ class NotificationController ( @PathVariable notificationId: Long ): ResponseEntity { val memberId = userAuthInfo.memberId - notificationService.readNotification(memberId, notificationId) + notificationFacade.readNotification(memberId, notificationId) return ResponseEntity.ok().build() } @@ -45,7 +45,7 @@ class NotificationController ( @AuthenticationPrincipal userAuthInfo: UserAuthInfo ): ResponseEntity { val memberId = userAuthInfo.memberId - notificationService.readAllNotifications(memberId) + notificationFacade.readAllNotifications(memberId) return ResponseEntity.ok().build() } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/facade/AdminNotificationFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/facade/AdminNotificationFacade.kt new file mode 100644 index 0000000..4a33508 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/facade/AdminNotificationFacade.kt @@ -0,0 +1,18 @@ +package site.billilge.api.backend.domain.notification.facade + +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.notification.dto.response.NotificationDetail +import site.billilge.api.backend.domain.notification.dto.response.NotificationFindAllResponse +import site.billilge.api.backend.domain.notification.service.NotificationService + +@Component +class AdminNotificationFacade( + private val notificationService: NotificationService, +) { + fun getAdminNotifications(memberId: Long?): NotificationFindAllResponse { + val notifications = notificationService.getAdminNotifications(memberId) + return NotificationFindAllResponse( + notifications.map { NotificationDetail.from(it) } + ) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/facade/NotificationFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/facade/NotificationFacade.kt new file mode 100644 index 0000000..8fa4d5e --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/facade/NotificationFacade.kt @@ -0,0 +1,32 @@ +package site.billilge.api.backend.domain.notification.facade + +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.notification.dto.response.NotificationCountResponse +import site.billilge.api.backend.domain.notification.dto.response.NotificationDetail +import site.billilge.api.backend.domain.notification.dto.response.NotificationFindAllResponse +import site.billilge.api.backend.domain.notification.service.NotificationService + +@Component +class NotificationFacade( + private val notificationService: NotificationService, +) { + fun getNotifications(memberId: Long?): NotificationFindAllResponse { + val notifications = notificationService.getNotifications(memberId) + return NotificationFindAllResponse( + notifications.map { NotificationDetail.from(it) } + ) + } + + fun getNotificationCount(memberId: Long?): NotificationCountResponse { + val count = notificationService.getNotificationCount(memberId) + return NotificationCountResponse(count) + } + + fun readNotification(memberId: Long?, notificationId: Long) { + notificationService.readNotification(memberId, notificationId) + } + + fun readAllNotifications(memberId: Long?) { + notificationService.readAllNotifications(memberId) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt index e3f9e80..ea2a690 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt @@ -5,10 +5,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import site.billilge.api.backend.domain.member.entity.Member import site.billilge.api.backend.domain.member.enums.Role -import site.billilge.api.backend.domain.member.repository.MemberRepository -import site.billilge.api.backend.domain.notification.dto.response.NotificationCountResponse -import site.billilge.api.backend.domain.notification.dto.response.NotificationDetail -import site.billilge.api.backend.domain.notification.dto.response.NotificationFindAllResponse +import site.billilge.api.backend.domain.member.service.MemberService import site.billilge.api.backend.domain.notification.entity.Notification import site.billilge.api.backend.domain.notification.enums.NotificationStatus import site.billilge.api.backend.domain.notification.exception.NotificationErrorCode @@ -23,15 +20,10 @@ private val log = KotlinLogging.logger {} class NotificationService( private val notificationRepository: NotificationRepository, private val fcmService: FCMService, - private val memberRepository: MemberRepository, + private val memberService: MemberService, ) { - fun getNotifications(memberId: Long?): NotificationFindAllResponse { - val notifications = notificationRepository.findAllUserNotificationsByMemberId(memberId!!) - - return NotificationFindAllResponse( - notifications - .map { NotificationDetail.from(it) } - ) + fun getNotifications(memberId: Long?): List { + return notificationRepository.findAllUserNotificationsByMemberId(memberId!!) } @Transactional @@ -48,12 +40,8 @@ class NotificationService( notification.readNotification() } - fun getAdminNotifications(memberId: Long?): NotificationFindAllResponse { - val notifications = notificationRepository.findAllAdminNotificationsByMemberId(memberId!!) - - return NotificationFindAllResponse( - notifications - .map { NotificationDetail.from(it) }) + fun getAdminNotifications(memberId: Long?): List { + return notificationRepository.findAllAdminNotificationsByMemberId(memberId!!) } @Transactional @@ -103,7 +91,7 @@ class NotificationService( formatValues: List, needPush: Boolean = false ) { - val admins = memberRepository.findAllByRole(Role.ADMIN) + val admins = memberService.findAllByRole(Role.ADMIN) val notification = Notification( status = type, @@ -119,10 +107,8 @@ class NotificationService( } } - fun getNotificationCount(memberId: Long?): NotificationCountResponse { - val count = notificationRepository.countUserNotificationsByMemberId(memberId!!) - - return NotificationCountResponse(count) + fun getNotificationCount(memberId: Long?): Int { + return notificationRepository.countUserNotificationsByMemberId(memberId!!) } @Transactional @@ -133,4 +119,4 @@ class NotificationService( } private fun Notification.isAdminStatus(): Boolean = status.name.contains("ADMIN", true) -} \ No newline at end of file +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt index e4183b1..2c67053 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt @@ -9,7 +9,8 @@ import org.springframework.web.bind.annotation.* import site.billilge.api.backend.domain.payer.dto.request.PayerDeleteRequest import site.billilge.api.backend.domain.payer.dto.request.PayerRequest import site.billilge.api.backend.domain.payer.dto.response.PayerFindAllResponse -import site.billilge.api.backend.domain.payer.service.PayerService +import site.billilge.api.backend.domain.payer.facade.AdminPayerFacade +import site.billilge.api.backend.domain.member.enums.Role import site.billilge.api.backend.global.annotation.OnlyAdmin import site.billilge.api.backend.global.dto.PageableCondition import site.billilge.api.backend.global.dto.SearchCondition @@ -18,33 +19,33 @@ import java.time.format.DateTimeFormatter @RestController @RequestMapping("/admin/members/payers") -@OnlyAdmin +@OnlyAdmin(roles = [Role.ADMIN, Role.GA]) class AdminPayerController( - private val payerService: PayerService + private val adminPayerFacade: AdminPayerFacade ) : AdminPayerApi { @GetMapping override fun getAllPayers( @ModelAttribute pageableCondition: PageableCondition, @ModelAttribute searchCondition: SearchCondition, ): ResponseEntity { - return ResponseEntity.ok(payerService.getAllPayers(pageableCondition, searchCondition)) + return ResponseEntity.ok(adminPayerFacade.getAllPayers(pageableCondition, searchCondition)) } @PostMapping override fun addPayers(@RequestBody request: PayerRequest): ResponseEntity { - payerService.addPayers(request) + adminPayerFacade.addPayers(request) return ResponseEntity.ok().build() } @DeleteMapping override fun deletePayers(@RequestBody request: PayerDeleteRequest): ResponseEntity { - payerService.deletePayers(request) + adminPayerFacade.deletePayers(request) return ResponseEntity.noContent().build() } @GetMapping("/excel") override fun createPayerExcel(): ResponseEntity { - val excel = payerService.createPayerExcel() + val excel = adminPayerFacade.createPayerExcel() val currentDate = LocalDate.now() val dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") val headers = HttpHeaders().apply { diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/facade/AdminPayerFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/facade/AdminPayerFacade.kt new file mode 100644 index 0000000..9afbe9e --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/facade/AdminPayerFacade.kt @@ -0,0 +1,35 @@ +package site.billilge.api.backend.domain.payer.facade + +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.payer.dto.request.PayerDeleteRequest +import site.billilge.api.backend.domain.payer.dto.request.PayerRequest +import site.billilge.api.backend.domain.payer.dto.response.PayerFindAllResponse +import site.billilge.api.backend.domain.payer.dto.response.PayerSummary +import site.billilge.api.backend.domain.payer.service.PayerService +import site.billilge.api.backend.global.dto.PageableCondition +import site.billilge.api.backend.global.dto.SearchCondition +import java.io.ByteArrayInputStream + +@Component +class AdminPayerFacade( + private val payerService: PayerService, +) { + fun getAllPayers(pageableCondition: PageableCondition, searchCondition: SearchCondition): PayerFindAllResponse { + val payers = payerService.getAllPayers(pageableCondition, searchCondition) + val payerSummaries = payers.map { PayerSummary.from(it) }.toList() + return PayerFindAllResponse(payerSummaries, payers.totalPages) + } + + fun addPayers(request: PayerRequest) { + val payers = request.payers.map { it.name to it.studentId } + payerService.addPayers(payers) + } + + fun deletePayers(request: PayerDeleteRequest) { + payerService.deletePayers(request.payerIds) + } + + fun createPayerExcel(): ByteArrayInputStream { + return payerService.createPayerExcel() + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt index 446fb8b..3d7e48d 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt @@ -1,15 +1,12 @@ package site.billilge.api.backend.domain.payer.service +import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import site.billilge.api.backend.domain.member.entity.Member import site.billilge.api.backend.domain.member.repository.MemberRepository -import site.billilge.api.backend.domain.payer.dto.request.PayerDeleteRequest -import site.billilge.api.backend.domain.payer.dto.request.PayerRequest -import site.billilge.api.backend.domain.payer.dto.response.PayerFindAllResponse -import site.billilge.api.backend.domain.payer.dto.response.PayerSummary import site.billilge.api.backend.domain.payer.entity.Payer import site.billilge.api.backend.domain.payer.repository.PayerRepository import site.billilge.api.backend.global.dto.PageableCondition @@ -62,26 +59,19 @@ class PayerService( payerResults[0].update(true, studentId) } - fun getAllPayers(pageableCondition: PageableCondition, searchCondition: SearchCondition): PayerFindAllResponse { + fun getAllPayers(pageableCondition: PageableCondition, searchCondition: SearchCondition): Page { val pageRequest = PageRequest.of( pageableCondition.pageNo, pageableCondition.size, Sort.by(Sort.Direction.DESC, pageableCondition.criteria ?: "enrollmentYear") ) - val payers = payerRepository.findAllByNameContaining(searchCondition.search, pageRequest) - val payerSummaries = payers - .map { PayerSummary.from(it) } - .toList() - - return PayerFindAllResponse(payerSummaries, payers.totalPages) + return payerRepository.findAllByNameContaining(searchCondition.search, pageRequest) } @Transactional - fun addPayers(request: PayerRequest) { + fun addPayers(payers: List>) { val newPayers = mutableListOf() - request.payers.forEach { payerItem -> - val name = payerItem.name - val studentId = payerItem.studentId + payers.forEach { (name, studentId) -> val enrollmentYear = studentId.substring(0, 4) val registeredMember = memberRepository.findByStudentIdAndName(studentId, name) val registered = registeredMember != null @@ -105,8 +95,8 @@ class PayerService( } @Transactional - fun deletePayers(request: PayerDeleteRequest) { - val payerStudentIds = payerRepository.findAllByIds(request.payerIds) + fun deletePayers(payerIds: List) { + val payerStudentIds = payerRepository.findAllByIds(payerIds) .mapNotNull { it.studentId } memberRepository.findAllByStudentIds(payerStudentIds) @@ -114,7 +104,7 @@ class PayerService( member.isFeePaid = false } - payerRepository.deleteAllById(request.payerIds) + payerRepository.deleteAllById(payerIds) } fun createPayerExcel(): ByteArrayInputStream { @@ -133,4 +123,4 @@ class PayerService( return excelGenerator.generateByMultipleSheets(sheetData) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/AdminRentalApi.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/AdminRentalApi.kt index ff45d8a..511670f 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/AdminRentalApi.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/AdminRentalApi.kt @@ -11,9 +11,11 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam import site.billilge.api.backend.domain.rental.dto.request.AdminRentalHistoryRequest +import site.billilge.api.backend.domain.rental.dto.request.ItemCodeUpdateRequest import site.billilge.api.backend.domain.rental.dto.request.RentalStatusUpdateRequest import site.billilge.api.backend.domain.rental.dto.response.AdminRentalHistoryFindAllResponse import site.billilge.api.backend.domain.rental.dto.response.DashboardResponse +import site.billilge.api.backend.domain.rental.dto.response.RentalStatusWorkerLogFindAllResponse import site.billilge.api.backend.domain.rental.enums.RentalStatus import site.billilge.api.backend.global.dto.PageableCondition import site.billilge.api.backend.global.dto.SearchCondition @@ -88,6 +90,39 @@ interface AdminRentalApi { @RequestBody request: AdminRentalHistoryRequest ): ResponseEntity + @Operation( + summary = "물품 코드 수정", + description = "대여 기록의 물품 코드를 수정하는 관리자용 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "물품 코드 수정 성공" + ) + ] + ) + fun updateItemCode( + @PathVariable rentalHistoryId: Long, + @RequestBody request: ItemCodeUpdateRequest + ): ResponseEntity + + @Operation( + summary = "대여 상태 변경 처리자 로그 조회", + description = "대여 기록의 상태별 처리자 로그를 조회하는 관리자용 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "처리자 로그 조회 성공" + ) + ] + ) + fun getWorkerLogs( + @PathVariable rentalHistoryId: Long + ): ResponseEntity + @Operation( summary = "대여 기록 삭제 (관리자용)", description = "대여 기록을 임의로 삭제하는 관리자용 API" diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/AdminRentalController.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/AdminRentalController.kt index 91273d6..d779bfa 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/AdminRentalController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/AdminRentalController.kt @@ -4,12 +4,14 @@ import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import site.billilge.api.backend.domain.rental.dto.request.AdminRentalHistoryRequest -import site.billilge.api.backend.domain.rental.dto.request.RentalHistoryRequest +import site.billilge.api.backend.domain.rental.dto.request.ItemCodeUpdateRequest import site.billilge.api.backend.domain.rental.dto.request.RentalStatusUpdateRequest import site.billilge.api.backend.domain.rental.dto.response.AdminRentalHistoryFindAllResponse import site.billilge.api.backend.domain.rental.dto.response.DashboardResponse +import site.billilge.api.backend.domain.rental.dto.response.RentalStatusWorkerLogFindAllResponse import site.billilge.api.backend.domain.rental.enums.RentalStatus -import site.billilge.api.backend.domain.rental.service.RentalService +import site.billilge.api.backend.domain.rental.facade.AdminRentalFacade +import site.billilge.api.backend.domain.member.enums.Role import site.billilge.api.backend.global.annotation.OnlyAdmin import site.billilge.api.backend.global.dto.PageableCondition import site.billilge.api.backend.global.dto.SearchCondition @@ -17,15 +19,15 @@ import site.billilge.api.backend.global.security.oauth2.UserAuthInfo @RestController @RequestMapping("/admin/rentals") -@OnlyAdmin +@OnlyAdmin(roles = [Role.ADMIN, Role.GA, Role.WORKER]) class AdminRentalController( - private val rentalService: RentalService + private val adminRentalFacade: AdminRentalFacade ) : AdminRentalApi { @GetMapping("/dashboard") override fun getAllDashboardApplications( @RequestParam(required = false) rentalStatus: RentalStatus?, ): ResponseEntity { - return ResponseEntity.ok(rentalService.getAllDashboardApplications(rentalStatus)) + return ResponseEntity.ok(adminRentalFacade.getAllDashboardApplications(rentalStatus)) } @GetMapping @@ -33,7 +35,7 @@ class AdminRentalController( @ModelAttribute pageableCondition: PageableCondition, @ModelAttribute searchCondition: SearchCondition ): ResponseEntity { - return ResponseEntity.ok(rentalService.getAllRentalHistories(pageableCondition, searchCondition)) + return ResponseEntity.ok(adminRentalFacade.getAllRentalHistories(pageableCondition, searchCondition)) } @PatchMapping("/{rentalHistoryId}") @@ -42,7 +44,7 @@ class AdminRentalController( @PathVariable rentalHistoryId: Long, @RequestBody request: RentalStatusUpdateRequest ): ResponseEntity { - rentalService.updateRentalStatus(userAuthInfo.memberId, rentalHistoryId, request) + adminRentalFacade.updateRentalStatus(userAuthInfo.memberId, rentalHistoryId, request) return ResponseEntity.ok().build() } @@ -50,13 +52,30 @@ class AdminRentalController( override fun addRentalHistory( @RequestBody request: AdminRentalHistoryRequest ): ResponseEntity { - rentalService.createRentalByAdmin(request) + adminRentalFacade.createRentalByAdmin(request) return ResponseEntity.ok().build() } + @PatchMapping("/{rentalHistoryId}/item-code") + override fun updateItemCode( + @PathVariable rentalHistoryId: Long, + @RequestBody request: ItemCodeUpdateRequest + ): ResponseEntity { + adminRentalFacade.updateItemCode(rentalHistoryId, request.itemCode) + return ResponseEntity.ok().build() + } + + @GetMapping("/{rentalHistoryId}/workers") + override fun getWorkerLogs( + @PathVariable rentalHistoryId: Long + ): ResponseEntity { + return ResponseEntity.ok(adminRentalFacade.getWorkerLogs(rentalHistoryId)) + } + + @OnlyAdmin @DeleteMapping("/{rentalHistoryId}") override fun deleteRentalHistory(@PathVariable rentalHistoryId: Long): ResponseEntity { - rentalService.deleteRentalHistory(rentalHistoryId) + adminRentalFacade.deleteRentalHistory(rentalHistoryId) return ResponseEntity.ok().build() } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/RentalController.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/RentalController.kt index f1d498c..3c802bb 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/RentalController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/controller/RentalController.kt @@ -8,13 +8,14 @@ import site.billilge.api.backend.domain.rental.dto.request.RentalHistoryRequest import site.billilge.api.backend.domain.rental.dto.response.RentalHistoryFindAllResponse import site.billilge.api.backend.domain.rental.dto.response.ReturnRequiredItemFindAllResponse import site.billilge.api.backend.domain.rental.enums.RentalStatus -import site.billilge.api.backend.domain.rental.service.RentalService +import site.billilge.api.backend.domain.rental.facade.RentalFacade +import site.billilge.api.backend.global.annotation.OnlyAdmin import site.billilge.api.backend.global.security.oauth2.UserAuthInfo @RestController @RequestMapping("/rentals") class RentalController( - private val rentalService: RentalService, + private val rentalFacade: RentalFacade, ) : RentalApi { @PostMapping @@ -23,17 +24,18 @@ class RentalController( @RequestBody rentalHistoryRequest: RentalHistoryRequest ): ResponseEntity { val memberId = userAuthInfo.memberId - rentalService.createRental(memberId, rentalHistoryRequest) + rentalFacade.createRental(memberId, rentalHistoryRequest) return ResponseEntity.status(HttpStatus.CREATED).build() } + @OnlyAdmin @PostMapping("/dev") override fun createDevRental( @AuthenticationPrincipal userAuthInfo: UserAuthInfo, @RequestBody rentalHistoryRequest: RentalHistoryRequest ): ResponseEntity { val memberId = userAuthInfo.memberId - rentalService.createRental(memberId, rentalHistoryRequest, true) + rentalFacade.createRental(memberId, rentalHistoryRequest, true) return ResponseEntity.status(HttpStatus.CREATED).build() } @@ -43,7 +45,7 @@ class RentalController( @RequestParam(required = false) rentalStatus: RentalStatus? ) : ResponseEntity { val memberId = userAuthInfo.memberId - return ResponseEntity.ok(rentalService.getMemberRentalHistory(memberId, rentalStatus)) + return ResponseEntity.ok(rentalFacade.getMemberRentalHistory(memberId, rentalStatus)) } @PatchMapping("/{rentalHistoryId}") @@ -51,7 +53,7 @@ class RentalController( @AuthenticationPrincipal userAuthInfo: UserAuthInfo, @PathVariable rentalHistoryId: Long): ResponseEntity { val memberId = userAuthInfo.memberId - rentalService.cancelRental(memberId, rentalHistoryId) + rentalFacade.cancelRental(memberId, rentalHistoryId) return ResponseEntity.status(HttpStatus.OK).build() } @@ -60,7 +62,7 @@ class RentalController( @AuthenticationPrincipal userAuthInfo: UserAuthInfo, @PathVariable rentalHistoryId: Long): ResponseEntity { val memberId = userAuthInfo.memberId - rentalService.returnRental(memberId, rentalHistoryId) + rentalFacade.returnRental(memberId, rentalHistoryId) return ResponseEntity.status(HttpStatus.OK).build() } @@ -69,6 +71,6 @@ class RentalController( @AuthenticationPrincipal userAuthInfo: UserAuthInfo ): ResponseEntity { val memberId = userAuthInfo.memberId - return ResponseEntity.ok(rentalService.getReturnRequiredItems(memberId)) + return ResponseEntity.ok(rentalFacade.getReturnRequiredItems(memberId)) } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/request/AdminRentalHistoryRequest.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/request/AdminRentalHistoryRequest.kt index 39353f4..4ed8ae6 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/request/AdminRentalHistoryRequest.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/request/AdminRentalHistoryRequest.kt @@ -14,7 +14,10 @@ data class AdminRentalHistoryRequest( val count: Int, @field:Schema(description = "대여 시작 시간 정보") - val rentalTime: RentalTime + val rentalTime: RentalTime, + + @field:Schema(description = "근무자 ID", example = "2") + val workerId: Long, ) { @Schema(description = "대여 시작 시간 (시간 및 분)") data class RentalTime( diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/request/ItemCodeUpdateRequest.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/request/ItemCodeUpdateRequest.kt new file mode 100644 index 0000000..f67ac60 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/request/ItemCodeUpdateRequest.kt @@ -0,0 +1,9 @@ +package site.billilge.api.backend.domain.rental.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema +data class ItemCodeUpdateRequest( + @field:Schema(description = "물품 코드", example = "우산1") + val itemCode: String +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/response/AdminRentalHistoryFindAllResponse.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/response/AdminRentalHistoryFindAllResponse.kt index c4ee4b3..8f2848b 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/response/AdminRentalHistoryFindAllResponse.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/response/AdminRentalHistoryFindAllResponse.kt @@ -23,6 +23,7 @@ data class AdminRentalHistoryFindAllResponse( val rentAt: LocalDateTime, val returnedAt: LocalDateTime?, val rentalStatus: RentalStatus, + val itemCode: String?, ) { companion object { @JvmStatic @@ -34,6 +35,7 @@ data class AdminRentalHistoryFindAllResponse( rentAt = rentalHistory.rentAt, returnedAt = rentalHistory.returnedAt, rentalStatus = rentalHistory.rentalStatus, + itemCode = rentalHistory.itemCode, ) } } diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/response/RentalStatusWorkerLogFindAllResponse.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/response/RentalStatusWorkerLogFindAllResponse.kt new file mode 100644 index 0000000..c6b661f --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/dto/response/RentalStatusWorkerLogFindAllResponse.kt @@ -0,0 +1,35 @@ +package site.billilge.api.backend.domain.rental.dto.response + +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Schema +import site.billilge.api.backend.domain.member.dto.response.MemberSummary +import site.billilge.api.backend.domain.rental.entity.RentalStatusWorkerLog +import site.billilge.api.backend.domain.rental.enums.RentalStatus +import java.time.LocalDateTime + +@Schema +data class RentalStatusWorkerLogFindAllResponse( + @field:ArraySchema(schema = Schema(implementation = RentalStatusWorkerLogDetail::class)) + val workers: List +) { + @Schema + data class RentalStatusWorkerLogDetail( + @field:Schema(description = "대여 상태", example = "CONFIRMED") + val rentalStatus: RentalStatus, + @field:Schema(description = "처리자 정보") + val worker: MemberSummary?, + @field:Schema(description = "상태 변경 시각") + val createdAt: LocalDateTime, + ) { + companion object { + @JvmStatic + fun from(log: RentalStatusWorkerLog): RentalStatusWorkerLogDetail { + return RentalStatusWorkerLogDetail( + rentalStatus = log.rentalStatus, + worker = log.worker?.let { MemberSummary.from(it) }, + createdAt = log.createdAt, + ) + } + } + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/entity/RentalHistory.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/entity/RentalHistory.kt index e1b42a5..e97ad49 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/rental/entity/RentalHistory.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/entity/RentalHistory.kt @@ -35,6 +35,9 @@ class RentalHistory( @Column(name = "rented_count", nullable = false) val rentedCount: Int, + + @Column(name = "item_code", nullable = true) + var itemCode: String? = null, ) { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -64,4 +67,8 @@ class RentalHistory( fun updateWorker(worker: Member) { this.worker = worker } + + fun updateItemCode(itemCode: String) { + this.itemCode = itemCode + } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/entity/RentalStatusWorkerLog.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/entity/RentalStatusWorkerLog.kt new file mode 100644 index 0000000..de20443 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/entity/RentalStatusWorkerLog.kt @@ -0,0 +1,35 @@ +package site.billilge.api.backend.domain.rental.entity + +import jakarta.persistence.* +import org.hibernate.annotations.OnDelete +import org.hibernate.annotations.OnDeleteAction +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import site.billilge.api.backend.domain.member.entity.Member +import site.billilge.api.backend.domain.rental.enums.RentalStatus +import java.time.LocalDateTime + +@Entity +@Table(name = "rental_status_worker_log") +class RentalStatusWorkerLog( + @JoinColumn(name = "rental_history_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + @OnDelete(action = OnDeleteAction.CASCADE) + val rentalHistory: RentalHistory, + + @Column(name = "rental_status", nullable = false) + @Enumerated(EnumType.STRING) + val rentalStatus: RentalStatus, + + @JoinColumn(name = "worker_id", nullable = true) + @ManyToOne(fetch = FetchType.LAZY) + @OnDelete(action = OnDeleteAction.SET_NULL) + val worker: Member? = null, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "rental_status_worker_log_id", nullable = false) + val id: Long? = null + + @Column(name = "created_at", nullable = false, updatable = false) + val createdAt: LocalDateTime = LocalDateTime.now() +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/exception/RentalErrorCode.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/exception/RentalErrorCode.kt index 5e86490..ba418bf 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/rental/exception/RentalErrorCode.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/exception/RentalErrorCode.kt @@ -16,5 +16,6 @@ enum class RentalErrorCode( INVALID_RENTAL_TIME_WEEKEND("주말에는 대여가 불가능합니다.", HttpStatus.BAD_REQUEST), RENTAL_NOT_FOUND("대여 기록을 찾을 수 없습니다", HttpStatus.NOT_FOUND), MEMBER_IS_NOT_PAYER("복지물품을 대여하려면 먼저 학생회비를 납부해주세요.", HttpStatus.FORBIDDEN), + MEMBER_IS_NOT_PAYER_ADMIN("학생회비를 납부하지 않은 회원입니다.", HttpStatus.FORBIDDEN), TODAY_IS_IN_EXAM_PERIOD("시험기간에는\n대여가 불가능합니다.\n양해 부탁드립니다.", HttpStatus.BAD_REQUEST), } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/facade/AdminRentalFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/facade/AdminRentalFacade.kt new file mode 100644 index 0000000..1ddbbb3 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/facade/AdminRentalFacade.kt @@ -0,0 +1,83 @@ +package site.billilge.api.backend.domain.rental.facade + +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import site.billilge.api.backend.domain.item.service.ItemService +import site.billilge.api.backend.domain.member.service.MemberService +import site.billilge.api.backend.domain.rental.dto.request.AdminRentalHistoryRequest +import site.billilge.api.backend.domain.rental.dto.request.RentalStatusUpdateRequest +import site.billilge.api.backend.domain.rental.dto.response.AdminRentalHistoryFindAllResponse +import site.billilge.api.backend.domain.rental.dto.response.DashboardResponse +import site.billilge.api.backend.domain.rental.dto.response.RentalStatusWorkerLogFindAllResponse +import site.billilge.api.backend.domain.rental.enums.RentalStatus +import site.billilge.api.backend.domain.rental.service.RentalService +import site.billilge.api.backend.global.dto.PageableCondition +import site.billilge.api.backend.global.dto.SearchCondition +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId + +@Component +class AdminRentalFacade( + private val rentalService: RentalService, + private val memberService: MemberService, + private val itemService: ItemService, +) { + fun getAllDashboardApplications(rentalStatus: RentalStatus?): DashboardResponse { + val rentalHistories = rentalService.getAllDashboardApplications(rentalStatus) + return DashboardResponse( + rentalHistories.map { DashboardResponse.RentalApplicationDetail.from(it) } + ) + } + + fun getAllRentalHistories(pageableCondition: PageableCondition, searchCondition: SearchCondition): AdminRentalHistoryFindAllResponse { + val results = rentalService.getAllRentalHistories(pageableCondition, searchCondition) + val adminRentalHistoryDetails = results + .map { AdminRentalHistoryFindAllResponse.AdminRentalHistoryDetail.from(it) } + .toList() + return AdminRentalHistoryFindAllResponse(adminRentalHistoryDetails, results.totalPages) + } + + @Transactional + fun updateRentalStatus(workerId: Long?, rentalHistoryId: Long, request: RentalStatusUpdateRequest) { + val worker = memberService.findById(workerId!!) + rentalService.updateRentalStatus(worker, rentalHistoryId, request.rentalStatus) + } + + @Transactional + fun createRentalByAdmin(request: AdminRentalHistoryRequest) { + val rentAt = resolveKoreanRentAt(request.rentalTime.hour, request.rentalTime.minute) + val worker = memberService.findById(request.workerId) + val member = memberService.findById(request.memberId) + val item = itemService.getItemById(request.itemId) + rentalService.createRentalByAdmin( + worker, + member, + item, + request.count, + rentAt + ) + } + + fun updateItemCode(rentalHistoryId: Long, itemCode: String) { + rentalService.updateItemCode(rentalHistoryId, itemCode) + } + + fun getWorkerLogs(rentalHistoryId: Long): RentalStatusWorkerLogFindAllResponse { + val logs = rentalService.getWorkerLogsByRentalHistoryId(rentalHistoryId) + return RentalStatusWorkerLogFindAllResponse( + logs.map { RentalStatusWorkerLogFindAllResponse.RentalStatusWorkerLogDetail.from(it) } + ) + } + + fun deleteRentalHistory(rentalHistoryId: Long) { + rentalService.deleteRentalHistory(rentalHistoryId) + } + + private fun resolveKoreanRentAt(hour: Int, minute: Int): LocalDateTime { + val koreanZone = ZoneId.of("Asia/Seoul") + val today = LocalDate.now(koreanZone) + return LocalDateTime.of(today, LocalTime.of(hour, minute)) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/facade/RentalFacade.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/facade/RentalFacade.kt new file mode 100644 index 0000000..c9eaec6 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/facade/RentalFacade.kt @@ -0,0 +1,67 @@ +package site.billilge.api.backend.domain.rental.facade + +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import site.billilge.api.backend.domain.item.service.ItemService +import site.billilge.api.backend.domain.member.service.MemberService +import site.billilge.api.backend.domain.rental.dto.request.RentalHistoryRequest +import site.billilge.api.backend.domain.rental.dto.response.RentalHistoryDetail +import site.billilge.api.backend.domain.rental.dto.response.RentalHistoryFindAllResponse +import site.billilge.api.backend.domain.rental.dto.response.ReturnRequiredItemDetail +import site.billilge.api.backend.domain.rental.dto.response.ReturnRequiredItemFindAllResponse +import site.billilge.api.backend.domain.rental.enums.RentalStatus +import site.billilge.api.backend.domain.rental.service.RentalService +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId + +@Component +class RentalFacade( + private val rentalService: RentalService, + private val memberService: MemberService, + private val itemService: ItemService, +) { + @Transactional + fun createRental(memberId: Long?, rentalHistoryRequest: RentalHistoryRequest, isDevMode: Boolean = false) { + val rentAt = resolveKoreanRentAt(rentalHistoryRequest.rentalTime.hour, rentalHistoryRequest.rentalTime.minute) + val member = memberService.findById(memberId!!) + val item = itemService.getItemById(rentalHistoryRequest.itemId) + rentalService.createRental( + member, + item, + rentalHistoryRequest.count, + rentAt, + rentalHistoryRequest.ignoreDuplicate, + isDevMode + ) + } + + fun getMemberRentalHistory(memberId: Long?, rentalStatus: RentalStatus?): RentalHistoryFindAllResponse { + val rentalHistories = rentalService.getMemberRentalHistory(memberId, rentalStatus) + return RentalHistoryFindAllResponse( + rentalHistories.map { RentalHistoryDetail.from(it) } + ) + } + + fun cancelRental(memberId: Long?, rentalHistoryId: Long) { + rentalService.cancelRental(memberId, rentalHistoryId) + } + + fun returnRental(memberId: Long?, rentalHistoryId: Long) { + rentalService.returnRental(memberId, rentalHistoryId) + } + + fun getReturnRequiredItems(memberId: Long?): ReturnRequiredItemFindAllResponse { + val rentalHistories = rentalService.getReturnRequiredItems(memberId) + return ReturnRequiredItemFindAllResponse( + rentalHistories.map { ReturnRequiredItemDetail.from(it) } + ) + } + + private fun resolveKoreanRentAt(hour: Int, minute: Int): LocalDateTime { + val koreanZone = ZoneId.of("Asia/Seoul") + val today = LocalDate.now(koreanZone) + return LocalDateTime.of(today, LocalTime.of(hour, minute)) + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/repository/RentalStatusWorkerLogRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/repository/RentalStatusWorkerLogRepository.kt new file mode 100644 index 0000000..94c64d2 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/repository/RentalStatusWorkerLogRepository.kt @@ -0,0 +1,8 @@ +package site.billilge.api.backend.domain.rental.repository + +import org.springframework.data.jpa.repository.JpaRepository +import site.billilge.api.backend.domain.rental.entity.RentalStatusWorkerLog + +interface RentalStatusWorkerLogRepository : JpaRepository { + fun findAllByRentalHistoryIdOrderByCreatedAtAsc(rentalHistoryId: Long): List +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/service/RentalService.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/service/RentalService.kt index 644ca0b..7433392 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/rental/service/RentalService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/service/RentalService.kt @@ -1,121 +1,62 @@ package site.billilge.api.backend.domain.rental.service -import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort -import org.springframework.format.annotation.DateTimeFormat import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import site.billilge.api.backend.domain.configvalue.enums.ConfigValueKeys +import site.billilge.api.backend.domain.configvalue.service.ConfigValueService +import site.billilge.api.backend.domain.item.entity.Item import site.billilge.api.backend.domain.item.enums.ItemType -import site.billilge.api.backend.domain.item.repository.ItemRepository +import site.billilge.api.backend.domain.member.entity.Member import site.billilge.api.backend.domain.member.enums.Role import site.billilge.api.backend.domain.member.exception.MemberErrorCode -import site.billilge.api.backend.domain.member.repository.MemberRepository import site.billilge.api.backend.domain.notification.enums.NotificationStatus import site.billilge.api.backend.domain.notification.service.NotificationService -import site.billilge.api.backend.domain.rental.dto.request.AdminRentalHistoryRequest -import site.billilge.api.backend.domain.rental.dto.request.RentalHistoryRequest -import site.billilge.api.backend.domain.rental.dto.request.RentalStatusUpdateRequest -import site.billilge.api.backend.domain.rental.dto.response.* import site.billilge.api.backend.domain.rental.entity.RentalHistory import site.billilge.api.backend.domain.rental.enums.RentalStatus import site.billilge.api.backend.domain.rental.exception.RentalErrorCode import site.billilge.api.backend.domain.rental.repository.RentalRepository +import site.billilge.api.backend.domain.rental.repository.RentalStatusWorkerLogRepository +import site.billilge.api.backend.domain.rental.entity.RentalStatusWorkerLog import site.billilge.api.backend.global.dto.PageableCondition import site.billilge.api.backend.global.dto.SearchCondition import site.billilge.api.backend.global.exception.ApiException import site.billilge.api.backend.global.utils.isWeekend import java.time.LocalDate import java.time.LocalDateTime -import java.time.LocalTime import java.time.ZoneId @Service @Transactional(readOnly = true) class RentalService( - private val memberRepository: MemberRepository, - private val rentalRepository: RentalRepository, - - private val itemRepository: ItemRepository, - + private val rentalStatusWorkerLogRepository: RentalStatusWorkerLogRepository, private val notificationService: NotificationService, - - @Value("\${exam-period.start-date}") - @DateTimeFormat(pattern="yyyy-MM-dd") - private val examPeriodStartDate: LocalDate, - - @Value("\${exam-period.end-date}") - @DateTimeFormat(pattern="yyyy-MM-dd") - private val examPeriodEndDate: LocalDate, + private val configValueService: ConfigValueService, ) { @Transactional - fun createRental(memberId: Long?, rentalHistoryRequest: RentalHistoryRequest, isDevMode: Boolean = false) { + fun createRental(rentUser: Member, item: Item, count: Int, rentAt: LocalDateTime, ignoreDuplicate: Boolean, isDevMode: Boolean = false) { + validatePayer(rentUser) + validateStock(count, item.count) - val item = itemRepository.findById(rentalHistoryRequest.itemId) - .orElseThrow { ApiException(RentalErrorCode.ITEM_NOT_FOUND) } - val rentedCount = rentalHistoryRequest.count - - if (!rentalHistoryRequest.ignoreDuplicate) { - val rentalHistory = rentalRepository.findByItemIdAndMemberIdAndRentalStatus( - rentalHistoryRequest.itemId, - memberId!!, - RentalStatus.RENTAL - ) - - if (rentalHistory.isPresent) - throw ApiException(RentalErrorCode.RENTAL_ITEM_DUPLICATED) + if (!ignoreDuplicate) { + checkDuplicateRental(item.id!!, rentUser.id!!) } - if (rentedCount > item.count) - throw ApiException(RentalErrorCode.ITEM_OUT_OF_STOCK) - - val rentUser = memberRepository.findById(memberId!!) - .orElseThrow { ApiException(RentalErrorCode.MEMBER_NOT_FOUND) } - - if (!rentUser.isFeePaid) - throw ApiException(RentalErrorCode.MEMBER_IS_NOT_PAYER) - if (isDevMode && rentUser.role != Role.ADMIN) throw ApiException(MemberErrorCode.FORBIDDEN) - val koreanZone = ZoneId.of("Asia/Seoul") - val today = LocalDate.now(koreanZone) - val requestedRentalDateTime = LocalDateTime.of( - today, - LocalTime.of(rentalHistoryRequest.rentalTime.hour, rentalHistoryRequest.rentalTime.minute) - ) - - if ((!isDevMode) && today.isInExamPeriod) { - throw ApiException(RentalErrorCode.TODAY_IS_IN_EXAM_PERIOD) - } - - val currentKoreanTime = LocalDateTime.now(koreanZone) if (!isDevMode) { - if (requestedRentalDateTime.isWeekend) { - throw ApiException(RentalErrorCode.INVALID_RENTAL_TIME_WEEKEND) - } - - if (requestedRentalDateTime.isBefore(currentKoreanTime)) { - throw ApiException(RentalErrorCode.INVALID_RENTAL_TIME_PAST) - } + validateRentalTime(rentAt) } - val rentalHour = requestedRentalDateTime.hour - val rentalMinute = requestedRentalDateTime.minute - if (!isDevMode) { - if (rentalHour < 10 || rentalHour > 17) { - throw ApiException(RentalErrorCode.INVALID_RENTAL_TIME_OUT_OF_RANGE) - } - } - - val rentAt = requestedRentalDateTime.atZone(koreanZone).toLocalDateTime() - val newRental = RentalHistory( member = rentUser, item = item, rentalStatus = RentalStatus.PENDING, - rentedCount = rentedCount, + rentedCount = count, rentAt = rentAt ) @@ -124,9 +65,7 @@ class RentalService( notificationService.sendNotification( rentUser, NotificationStatus.USER_RENTAL_APPLY, - listOf( - item.name - ), + listOf(item.name), true ) @@ -136,7 +75,7 @@ class RentalService( listOf( rentUser.name, rentUser.studentId, - "${String.format("%02d", rentalHour)}:${String.format("%02d", rentalMinute)}", + "${String.format("%02d", rentAt.hour)}:${String.format("%02d", rentAt.minute)}", item.name ), true @@ -145,35 +84,16 @@ class RentalService( } @Transactional - fun createRentalByAdmin(request: AdminRentalHistoryRequest) { - - val item = itemRepository.findById(request.itemId) - .orElseThrow { ApiException(RentalErrorCode.ITEM_NOT_FOUND) } - val rentedCount = request.count - - if (rentedCount > item.count) - throw ApiException(RentalErrorCode.ITEM_OUT_OF_STOCK) + fun createRentalByAdmin(worker: Member, rentUser: Member, item: Item, count: Int, rentAt: LocalDateTime) { + validateStock(count, item.count) - val rentUser = memberRepository.findById(request.memberId) - .orElseThrow { ApiException(RentalErrorCode.MEMBER_NOT_FOUND) } - - if (!rentUser.isFeePaid) - throw ApiException(RentalErrorCode.MEMBER_IS_NOT_PAYER) - - val koreanZone = ZoneId.of("Asia/Seoul") - val today = LocalDate.now(koreanZone) - val requestedRentalDateTime = LocalDateTime.of( - today, - LocalTime.of(request.rentalTime.hour, request.rentalTime.minute) - ) - - val rentAt = requestedRentalDateTime.atZone(koreanZone).toLocalDateTime() + if (!rentUser.isFeePaid) throw ApiException(RentalErrorCode.MEMBER_IS_NOT_PAYER_ADMIN) val newRental = RentalHistory( member = rentUser, item = item, rentalStatus = if (item.type == ItemType.RENTAL) RentalStatus.RENTAL else RentalStatus.RETURNED, - rentedCount = rentedCount, + rentedCount = count, rentAt = rentAt ).apply { if (rentalStatus == RentalStatus.RETURNED) { @@ -182,6 +102,14 @@ class RentalService( } rentalRepository.save(newRental) + + rentalStatusWorkerLogRepository.save( + RentalStatusWorkerLog( + rentalHistory = newRental, + rentalStatus = newRental.rentalStatus, + worker = worker + ) + ) } @Transactional @@ -189,15 +117,12 @@ class RentalService( rentalRepository.deleteById(rentalHistoryId) } - fun getMemberRentalHistory(memberId: Long?, rentalStatus: RentalStatus?): RentalHistoryFindAllResponse { - val rentalHistories = if (rentalStatus == null) { + fun getMemberRentalHistory(memberId: Long?, rentalStatus: RentalStatus?): List { + return if (rentalStatus == null) { rentalRepository.findByMemberId(memberId!!) } else { rentalRepository.findByMemberIdAndRentalStatus(memberId!!, rentalStatus) } - return RentalHistoryFindAllResponse( - rentalHistories - .map { rentalHistory -> RentalHistoryDetail.from(rentalHistory) }) } @Transactional @@ -247,60 +172,47 @@ class RentalService( ) } - fun getReturnRequiredItems(memberId: Long?): ReturnRequiredItemFindAllResponse { - val returnRequiredItems = rentalRepository.findByMemberIdAndRentalStatusIn(memberId!!, RETURN_REQUIRED_STATUS); - - return ReturnRequiredItemFindAllResponse( - returnRequiredItems.map { ReturnRequiredItemDetail.from(it) }) + fun getReturnRequiredItems(memberId: Long?): List { + return rentalRepository.findByMemberIdAndRentalStatusIn(memberId!!, RETURN_REQUIRED_STATUS) } - fun getAllDashboardApplications(rentalStatus: RentalStatus?): DashboardResponse { - val rentalApplicationDetails = rentalRepository.findAllByRentalStatusIn(DASHBOARD_STATUS) + fun getAllDashboardApplications(rentalStatus: RentalStatus?): List { + return rentalRepository.findAllByRentalStatusIn(DASHBOARD_STATUS) .filter { if (rentalStatus == null) true else it.rentalStatus == rentalStatus } - .map { DashboardResponse.RentalApplicationDetail.from(it) } - - return DashboardResponse(rentalApplicationDetails) } fun getAllRentalHistories( pageableCondition: PageableCondition, searchCondition: SearchCondition - ): AdminRentalHistoryFindAllResponse { + ): Page { val pageRequest = PageRequest.of( pageableCondition.pageNo, pageableCondition.size, Sort.by(Sort.Direction.DESC, pageableCondition.criteria ?: "applicatedAt") ) - val results = rentalRepository.findAllByMemberNameContaining(searchCondition.search, pageRequest) - val adminRentalHistoryDetails = results - .map { AdminRentalHistoryFindAllResponse.AdminRentalHistoryDetail.from(it) } - .toList() - - return AdminRentalHistoryFindAllResponse(adminRentalHistoryDetails, results.totalPages) + return rentalRepository.findAllByMemberNameContaining(searchCondition.search, pageRequest) } @Transactional - fun updateRentalStatus(workerId: Long?, rentalHistoryId: Long, request: RentalStatusUpdateRequest) { + fun updateRentalStatus(worker: Member, rentalHistoryId: Long, rentalStatus: RentalStatus) { val rentalHistory = rentalRepository.findById(rentalHistoryId) .orElseThrow { ApiException(RentalErrorCode.RENTAL_NOT_FOUND) } val renter = rentalHistory.member val item = rentalHistory.item val rentedCount = rentalHistory.rentedCount - if (request.rentalStatus == RentalStatus.CONFIRMED && item.count <= 0) { + if (rentalStatus == RentalStatus.CONFIRMED && item.count <= 0) { throw ApiException(RentalErrorCode.ITEM_OUT_OF_STOCK) } val newStatus = - if (request.rentalStatus == RentalStatus.RENTAL && item.type == ItemType.CONSUMPTION) + if (rentalStatus == RentalStatus.RENTAL && item.type == ItemType.CONSUMPTION) RentalStatus.RETURNED - else request.rentalStatus + else rentalStatus rentalHistory.updateStatus(newStatus) val itemName = item.name - val worker = memberRepository.findById(workerId!!) - .orElseThrow { ApiException(MemberErrorCode.MEMBER_NOT_FOUND) } when (rentalHistory.rentalStatus) { RentalStatus.CONFIRMED -> { @@ -310,7 +222,6 @@ class RentalService( } item.subtractCount(rentalHistory.rentedCount) - itemRepository.save(item) rentalHistory.updateWorker(worker) notificationService.sendNotification( @@ -352,7 +263,6 @@ class RentalService( if (item.type == ItemType.CONSUMPTION) return item.addCount(rentalHistory.rentedCount) - itemRepository.save(item) notificationService.sendNotification( renter, NotificationStatus.USER_RETURN_COMPLETED, @@ -364,10 +274,70 @@ class RentalService( else -> return } + + rentalStatusWorkerLogRepository.save( + RentalStatusWorkerLog( + rentalHistory = rentalHistory, + rentalStatus = newStatus, + worker = worker + ) + ) + } + + private fun validatePayer(member: Member) { + if (!member.isFeePaid) + throw ApiException(RentalErrorCode.MEMBER_IS_NOT_PAYER) + } + + private fun validateStock(rentedCount: Int, availableCount: Int) { + if (rentedCount > availableCount) + throw ApiException(RentalErrorCode.ITEM_OUT_OF_STOCK) + } + + private fun checkDuplicateRental(itemId: Long, memberId: Long) { + val rentalHistory = rentalRepository.findByItemIdAndMemberIdAndRentalStatus( + itemId, memberId, RentalStatus.RENTAL + ) + if (rentalHistory.isPresent) + throw ApiException(RentalErrorCode.RENTAL_ITEM_DUPLICATED) + } + + private fun validateRentalTime(rentAt: LocalDateTime) { + val today = rentAt.toLocalDate() + if (today.isInExamPeriod) + throw ApiException(RentalErrorCode.TODAY_IS_IN_EXAM_PERIOD) + + if (rentAt.isWeekend) + throw ApiException(RentalErrorCode.INVALID_RENTAL_TIME_WEEKEND) + + val currentKoreanTime = LocalDateTime.now(ZoneId.of("Asia/Seoul")) + if (rentAt.isBefore(currentKoreanTime)) + throw ApiException(RentalErrorCode.INVALID_RENTAL_TIME_PAST) + + if (rentAt.hour < 10 || rentAt.hour > 17) + throw ApiException(RentalErrorCode.INVALID_RENTAL_TIME_OUT_OF_RANGE) } private val LocalDate.isInExamPeriod - get() = this in (examPeriodStartDate..examPeriodEndDate) + get(): Boolean { + val configMap = configValueService.getMapByKeys( + listOf(ConfigValueKeys.EXAM_PERIOD_START_DATE.key, ConfigValueKeys.EXAM_PERIOD_END_DATE.key) + ) + val startDate = LocalDate.parse(configMap[ConfigValueKeys.EXAM_PERIOD_START_DATE.key] ?: return false) + val endDate = LocalDate.parse(configMap[ConfigValueKeys.EXAM_PERIOD_END_DATE.key] ?: return false) + return this in (startDate..endDate) + } + + @Transactional + fun updateItemCode(rentalHistoryId: Long, itemCode: String) { + val rentalHistory = rentalRepository.findById(rentalHistoryId) + .orElseThrow { ApiException(RentalErrorCode.RENTAL_NOT_FOUND) } + rentalHistory.updateItemCode(itemCode) + } + + fun getWorkerLogsByRentalHistoryId(rentalHistoryId: Long): List { + return rentalStatusWorkerLogRepository.findAllByRentalHistoryIdOrderByCreatedAtAsc(rentalHistoryId) + } companion object { private val DASHBOARD_STATUS = listOf( @@ -379,4 +349,4 @@ class RentalService( private val RETURN_REQUIRED_STATUS = listOf(RentalStatus.RENTAL, RentalStatus.RETURN_PENDING, RentalStatus.RETURN_CONFIRMED) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/site/billilge/api/backend/global/annotation/OnlyAdmin.kt b/src/main/kotlin/site/billilge/api/backend/global/annotation/OnlyAdmin.kt index c860f5d..8cbc1c3 100644 --- a/src/main/kotlin/site/billilge/api/backend/global/annotation/OnlyAdmin.kt +++ b/src/main/kotlin/site/billilge/api/backend/global/annotation/OnlyAdmin.kt @@ -1,6 +1,6 @@ package site.billilge.api.backend.global.annotation -import org.springframework.security.access.prepost.PreAuthorize +import site.billilge.api.backend.domain.member.enums.Role @Target( AnnotationTarget.FUNCTION, @@ -11,5 +11,6 @@ import org.springframework.security.access.prepost.PreAuthorize @Retention( AnnotationRetention.RUNTIME ) -@PreAuthorize("hasRole('ROLE_ADMIN')") -annotation class OnlyAdmin \ No newline at end of file +annotation class OnlyAdmin( + val roles: Array = [Role.ADMIN] +) diff --git a/src/main/kotlin/site/billilge/api/backend/global/annotation/OnlyAdminAspect.kt b/src/main/kotlin/site/billilge/api/backend/global/annotation/OnlyAdminAspect.kt new file mode 100644 index 0000000..b05d189 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/global/annotation/OnlyAdminAspect.kt @@ -0,0 +1,33 @@ +package site.billilge.api.backend.global.annotation + +import org.aspectj.lang.JoinPoint +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.annotation.Before +import org.aspectj.lang.reflect.MethodSignature +import org.springframework.security.authorization.AuthorizationDeniedException +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component + +@Aspect +@Component +class OnlyAdminAspect { + + @Before("@within(site.billilge.api.backend.global.annotation.OnlyAdmin) || @annotation(site.billilge.api.backend.global.annotation.OnlyAdmin)") + fun checkRole(joinPoint: JoinPoint) { + val methodSignature = joinPoint.signature as MethodSignature + val method = methodSignature.method + + val annotation = method.getAnnotation(OnlyAdmin::class.java) + ?: joinPoint.target.javaClass.getAnnotation(OnlyAdmin::class.java) + ?: return + + val allowedRoles = annotation.roles.map { it.key }.toSet() + + val authentication = SecurityContextHolder.getContext().authentication + val authorities = authentication?.authorities?.map { it.authority }?.toSet() ?: emptySet() + + if (authorities.none { it in allowedRoles }) { + throw AuthorizationDeniedException("Access Denied") + } + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/global/config/SecurityConfig.kt b/src/main/kotlin/site/billilge/api/backend/global/config/SecurityConfig.kt index 34fd5e2..97ed385 100644 --- a/src/main/kotlin/site/billilge/api/backend/global/config/SecurityConfig.kt +++ b/src/main/kotlin/site/billilge/api/backend/global/config/SecurityConfig.kt @@ -62,6 +62,7 @@ class SecurityConfig ( .authorizeHttpRequests { httpRequests -> httpRequests .requestMatchers("/auth/**").permitAll() + .requestMatchers("/display").permitAll() .requestMatchers(*SWAGGER_API_PATH).permitAll() .anyRequest().authenticated() } diff --git a/src/main/kotlin/site/billilge/api/backend/global/external/FileStorageService.kt b/src/main/kotlin/site/billilge/api/backend/global/external/FileStorageService.kt new file mode 100644 index 0000000..7724e5b --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/global/external/FileStorageService.kt @@ -0,0 +1,10 @@ +package site.billilge.api.backend.global.external + +import org.springframework.web.multipart.MultipartFile +import java.util.UUID + +interface FileStorageService { + fun uploadImageFile(imageFile: MultipartFile, newFileName: String = "items/${UUID.randomUUID()}"): String? + + fun deleteImageFile(fileName: String) +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/global/external/minio/MinioConfig.kt b/src/main/kotlin/site/billilge/api/backend/global/external/minio/MinioConfig.kt new file mode 100644 index 0000000..c18ce8f --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/global/external/minio/MinioConfig.kt @@ -0,0 +1,27 @@ +package site.billilge.api.backend.global.external.minio + +import io.minio.MinioClient +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class MinioConfig( + @Value("\${minio.endpoint}") + private val endpoint: String, + + @Value("\${minio.access-key}") + private val accessKey: String, + + @Value("\${minio.secret-key}") + private val secretKey: String, +) { + + @Bean + fun minioClient(): MinioClient { + return MinioClient.builder() + .endpoint(endpoint) + .credentials(accessKey, secretKey) + .build() + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/global/external/minio/MinioService.kt b/src/main/kotlin/site/billilge/api/backend/global/external/minio/MinioService.kt new file mode 100644 index 0000000..c8f2bd4 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/global/external/minio/MinioService.kt @@ -0,0 +1,73 @@ +package site.billilge.api.backend.global.external.minio + +import io.minio.MinioClient +import io.minio.PutObjectArgs +import io.minio.RemoveObjectArgs +import io.minio.StatObjectArgs +import io.minio.errors.ErrorResponseException +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import site.billilge.api.backend.global.exception.ApiException +import site.billilge.api.backend.global.exception.GlobalErrorCode +import site.billilge.api.backend.global.external.FileStorageService +import java.io.IOException +import java.util.* + +@Service +class MinioService( + private val minioClient: MinioClient, + @Value("\${minio.bucket}") + private val bucket: String, + @Value("\${minio.base-url}") + private val baseUrl: String, +) : FileStorageService { + + override fun uploadImageFile(imageFile: MultipartFile, newFileName: String): String? { + val originalName = imageFile.originalFilename ?: return null + + val ext = originalName.substring(originalName.lastIndexOf(".")) + val changedName = newFileName + ext + + try { + minioClient.putObject( + PutObjectArgs.builder() + .bucket(bucket) + .`object`(changedName) + .stream(imageFile.inputStream, imageFile.size, -1) + .contentType(imageFile.contentType) + .build() + ) + } catch (e: IOException) { + throw ApiException(GlobalErrorCode.IMAGE_UPLOAD_FAILED, e) + } + + return "${baseUrl}/${bucket}/${changedName}" + } + + override fun deleteImageFile(fileName: String) { + val imageKey = fileName.replace("${baseUrl}/${bucket}/", "") + + try { + minioClient.statObject( + StatObjectArgs.builder() + .bucket(bucket) + .`object`(imageKey) + .build() + ) + } catch (e: ErrorResponseException) { + throw ApiException(GlobalErrorCode.IMAGE_NOT_FOUND) + } + + try { + minioClient.removeObject( + RemoveObjectArgs.builder() + .bucket(bucket) + .`object`(imageKey) + .build() + ) + } catch (e: IOException) { + throw ApiException(GlobalErrorCode.IMAGE_DELETE_FAILED, e) + } + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/global/external/s3/S3Config.kt b/src/main/kotlin/site/billilge/api/backend/global/external/s3/S3Config.kt deleted file mode 100644 index 744cf48..0000000 --- a/src/main/kotlin/site/billilge/api/backend/global/external/s3/S3Config.kt +++ /dev/null @@ -1,33 +0,0 @@ -package site.billilge.api.backend.global.external.s3 - -import com.amazonaws.auth.AWSStaticCredentialsProvider -import com.amazonaws.auth.BasicAWSCredentials -import com.amazonaws.services.s3.AmazonS3 -import com.amazonaws.services.s3.AmazonS3ClientBuilder -import org.springframework.beans.factory.annotation.Value -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration - -@Configuration -class S3Config( - @Value("\${cloud.aws.credentials.access-key}") - private val accessKey: String, - - @Value("\${cloud.aws.credentials.secret-key}") - private val secretKey: String, - - @Value("\${cloud.aws.region.static}") - private val region: String -) { - - @Bean - fun amazonS3Client(): AmazonS3 { - val credentials = BasicAWSCredentials(accessKey, secretKey) - - return AmazonS3ClientBuilder - .standard() - .withCredentials(AWSStaticCredentialsProvider(credentials)) - .withRegion(region) - .build() - } -} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/global/external/s3/S3Service.kt b/src/main/kotlin/site/billilge/api/backend/global/external/s3/S3Service.kt deleted file mode 100644 index 98b6f34..0000000 --- a/src/main/kotlin/site/billilge/api/backend/global/external/s3/S3Service.kt +++ /dev/null @@ -1,61 +0,0 @@ -package site.billilge.api.backend.global.external.s3 - -import com.amazonaws.services.s3.AmazonS3 -import com.amazonaws.services.s3.model.ObjectMetadata -import com.amazonaws.services.s3.model.PutObjectRequest -import org.springframework.beans.factory.annotation.Value -import org.springframework.stereotype.Service -import org.springframework.web.multipart.MultipartFile -import site.billilge.api.backend.global.exception.ApiException -import site.billilge.api.backend.global.exception.GlobalErrorCode -import java.io.IOException -import java.util.* - -@Service -class S3Service( - private val amazonS3: AmazonS3, - @Value("\${cloud.aws.s3.bucket}") - val bucket: String, - @Value("\${cloud.aws.s3.base-url}") - val baseUrl: String, - @Value("\${spring.profiles.active}") - val activeProfile: String, -) { - fun uploadImageFile(imageFile: MultipartFile, newFileName: String = "items/${UUID.randomUUID()}"): String? { - val originalName = imageFile.originalFilename ?: return null - - - val ext = originalName.substring(originalName.lastIndexOf(".")) - val fileNameByProfile = if (activeProfile == "dev") "dev/${newFileName}" else newFileName - val changedName = fileNameByProfile + ext - - val metadata = ObjectMetadata().apply { - contentType = imageFile.contentType - contentLength = imageFile.size - } - - try { - amazonS3.putObject( - PutObjectRequest(bucket, changedName, imageFile.inputStream, metadata) - ) - } catch (e: IOException) { - throw ApiException(GlobalErrorCode.IMAGE_UPLOAD_FAILED, e) - } - - return amazonS3.getUrl(bucket, changedName).toString() - } - - fun deleteImageFile(imageUrl: String) { - val imageKey = imageUrl.replace(baseUrl, "") - - if (!amazonS3.doesObjectExist(bucket, imageKey)) { - throw ApiException(GlobalErrorCode.IMAGE_NOT_FOUND) - } - - try { - amazonS3.deleteObject(bucket, imageKey) - } catch (e: IOException) { - throw ApiException(GlobalErrorCode.IMAGE_DELETE_FAILED, e) - } - } -} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 1202e12..b8698da 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -3,10 +3,10 @@ spring: name: backend datasource: - url: jdbc:mysql://${DEV_DB_URL}?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true + url: jdbc:mysql://${DB_URL}?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true driver-class-name: com.mysql.cj.jdbc.Driver - username: ${DEV_DB_USERNAME} - password: ${DEV_DB_PASSWORD} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} security: oauth2: @@ -14,9 +14,9 @@ spring: registration: # 구글 로그인 추가 google: - client-id: ${DEV_OAUTH2_GOOGLE_CLIENT_ID} - client-secret: ${DEV_OAUTH2_GOOGLE_CLIENT_SECRET} - redirect-uri: ${DEV_SWAGGER_SERVER_BASE_URL}/login/oauth2/code/google + client-id: ${OAUTH2_GOOGLE_CLIENT_ID} + client-secret: ${OAUTH2_GOOGLE_CLIENT_SECRET} + redirect-uri: ${SWAGGER_SERVER_BASE_URL}/login/oauth2/code/google scope: - email - profile @@ -38,33 +38,22 @@ spring: jwt: - secret_key: ${DEV_JWT_SECRET_KEY} - issuer: ${DEV_JWT_ISSUER} + secret_key: ${JWT_SECRET_KEY} + issuer: ${JWT_ISSUER} login: - admin-password: ${DEV_LOGIN_ADMIN_PASSWORD} - redirect-url: ${DEV_LOGIN_REDIRECT_URL} + redirect-url: ${LOGIN_REDIRECT_URL} cors: - allowed-origins: ${DEV_CORS_ALLOWED_ORIGINS} + allowed-origins: ${CORS_ALLOWED_ORIGINS} -exam-period: - start-date: ${DEV_EXAM_PERIOD_START_DATE} - end-date: ${DEV_EXAM_PERIOD_END_DATE} - -cloud: - aws: - s3: - bucket: ${DEV_S3_BUCKET} - base-url: ${DEV_S3_BASE_URL} - credentials: - access-key: ${DEV_S3_ACCESS_KEY} - secret-key: ${DEV_S3_SECRET_KEY} - region: - static: us-west-2 - stack: - auto: false +minio: + endpoint: ${MINIO_ENDPOINT} + access-key: ${MINIO_ACCESS_KEY} + secret-key: ${MINIO_SECRET_KEY} + bucket: ${MINIO_BUCKET} + base-url: ${MINIO_BASE_URL} swagger: server: - base-url: ${DEV_SWAGGER_SERVER_BASE_URL} \ No newline at end of file + base-url: ${SWAGGER_SERVER_BASE_URL} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 6a207c6..affacfe 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -41,28 +41,17 @@ jwt: issuer: ${JWT_ISSUER} login: - admin-password: ${LOGIN_ADMIN_PASSWORD} redirect-url: ${LOGIN_REDIRECT_URL} cors: allowed-origins: ${CORS_ALLOWED_ORIGINS} -exam-period: - start-date: ${EXAM_PERIOD_START_DATE} - end-date: ${EXAM_PERIOD_END_DATE} - -cloud: - aws: - s3: - bucket: ${S3_BUCKET} - base-url: ${S3_BASE_URL} - credentials: - access-key: ${S3_ACCESS_KEY} - secret-key: ${S3_SECRET_KEY} - region: - static: us-west-2 - stack: - auto: false +minio: + endpoint: ${MINIO_ENDPOINT} + access-key: ${MINIO_ACCESS_KEY} + secret-key: ${MINIO_SECRET_KEY} + bucket: ${MINIO_BUCKET} + base-url: ${MINIO_BASE_URL} swagger: server: