diff --git "a/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" new file mode 100644 index 0000000..53874f2 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" @@ -0,0 +1,17 @@ +--- +name: "♻️ Refactor" +about: 리팩토링 +title: "♻️" +labels: '' +assignees: '' + +--- + +### 📌 Description + + +--- + +### ✅ Task +- [ ] Task 1 +- [ ] Task 2 diff --git "a/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-setting.md" "b/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-setting.md" new file mode 100644 index 0000000..e8ae8c8 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-setting.md" @@ -0,0 +1,17 @@ +--- +name: "⚙️ Setting" +about: 배포, 인프라 등 설정 관련 +title: "⚙️" +labels: '' +assignees: '' + +--- + +### 📌 Description + + +--- + +### ✅ Task +- [ ] Task 1 +- [ ] Task 2 diff --git "a/.github/ISSUE_TEMPLATE/\342\234\205-test.md" "b/.github/ISSUE_TEMPLATE/\342\234\205-test.md" new file mode 100644 index 0000000..d1d2ccf --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\234\205-test.md" @@ -0,0 +1,17 @@ +--- +name: "✅ Test" +about: 테스트 추가 +title: "✅" +labels: '' +assignees: '' + +--- + +### 📌 Description + + +--- + +### ✅ Task +- [ ] Task 1 +- [ ] Task 2 diff --git "a/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" "b/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" new file mode 100644 index 0000000..78aa92a --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" @@ -0,0 +1,17 @@ +--- +name: "✨ Feature" +about: 새로운 기능 추가 +title: "✨" +labels: '' +assignees: '' + +--- + +### 📌 Description + + +--- + +### ✅ Task +- [ ] Task 1 +- [ ] Task 2 diff --git "a/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" "b/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" new file mode 100644 index 0000000..6496eb7 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" @@ -0,0 +1,17 @@ +--- +name: "\U0001F41B Fix" +about: 버그 수정 +title: "\U0001F41B" +labels: '' +assignees: '' + +--- + +### 📌 Description + + +--- + +### ✅ Task +- [ ] Task 1 +- [ ] Task 2 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d1cd555 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +### ✨ Related Issue + +--- + +### 📌 Task Details +- Task 1 +- Task 2 + +--- + +### 💬 Review Requirements (Optional) + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..54ecc84 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,195 @@ +on: + push: + branches: [ "main", "dev" ] + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.vars.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + - name: JDK 17 세팅 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Gradle 캐시 + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle + + deploy-dev: + if: github.ref == 'refs/heads/dev' + needs: build-and-push + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up SSH key + uses: webfactory/ssh-agent@v0.5.3 + with: + ssh-private-key: ${{ secrets.DEV_EC2_SSH_KEY }} + + - name: YML 파일 세팅 + env: + APPLICATION_PROPERTIES: ${{ secrets.ONKU_APP_DEV_YML }} + TEST_APPLICATION_PROPERTIES: ${{ secrets.ONKU_APP_TEST_YML }} + FCM_JSON: ${{ secrets.ONKU_FCM_JSON }} + + run: | + cd ./src + rm -rf main/resources/application.yml + mkdir -p main/resources + mkdir -p test/resources + mkdir -p main/resources/firebase + echo "$APPLICATION_PROPERTIES" > main/resources/application.yml + echo "$TEST_APPLICATION_PROPERTIES" > test/resources/application.yml + echo "$FCM_JSON" > main/resources/firebase/fcm.json + + - name: gradlew 권한 부여 + run: chmod +x gradlew + + - name: 테스트 수행 + run: ./gradlew test + + - name: 테스트 리포트 아티팩트 업로드 + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test + + - name: 스프링부트 빌드 + run: ./gradlew build + + - name: Docker Buildx 세팅 + uses: docker/setup-buildx-action@v3 + + - name: docker login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: 도커 이미지 빌드 후 푸시 + if: success() + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/backend:${{ github.sha }} + platforms: linux/amd64,linux/arm64 + + - name: Docker Compose 파일 NCP 서버로 전송 + run: scp -o StrictHostKeyChecking=no -P ${{ secrets.DEV_EC2_PORT }} docker-compose.yml ${{ secrets.DEV_EC2_USER }}@${{ secrets.DEV_EC2_HOST }}:./ + + - name: EC2 접속 후 이미지 다운로드 및 배포 + if: success() + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + port: ${{ secrets.DEV_EC2_PORT }} + script: | + export DOCKER_CONTAINER_REGISTRY=${{ secrets.DOCKERHUB_USERNAME }} + export REDIS_PASSWORD=${{ secrets.DEV_REDIS_PASSWORD }} + export GITHUB_SHA=${{ github.sha }} + sudo chmod +x ./deploy.sh + ./deploy.sh + deploy-prod: + if: github.ref == 'refs/heads/main' + needs: build-and-push + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up SSH key + uses: webfactory/ssh-agent@v0.5.3 + with: + ssh-private-key: ${{ secrets.PROD_EC2_SSH_KEY }} + + - name: YML 파일 세팅 + env: + APPLICATION_PROPERTIES: ${{ secrets.ONKU_APP_PROD_YML }} + TEST_APPLICATION_PROPERTIES: ${{ secrets.ONKU_APP_TEST_YML }} + FCM_JSON: ${{ secrets.ONKU_FCM_JSON }} + + run: | + cd ./src + rm -rf main/resources/application.yml + mkdir -p main/resources + mkdir -p test/resources + mkdir -p main/resources/firebase + echo "$APPLICATION_PROPERTIES" > main/resources/application.yml + echo "$TEST_APPLICATION_PROPERTIES" > test/resources/application.yml + echo "$FCM_JSON" > main/resources/firebase/fcm.json + + - name: gradlew 권한 부여 + run: chmod +x gradlew + + - name: 테스트 수행 + run: ./gradlew test + + - name: 테스트 리포트 아티팩트 업로드 + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test + + - name: 스프링부트 빌드 + run: ./gradlew build + + - name: Docker Buildx 세팅 + uses: docker/setup-buildx-action@v3 + + - name: docker login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: 도커 이미지 빌드 후 푸시 + if: success() + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/backend:${{ github.sha }} + platforms: linux/amd64,linux/arm64 + + - name: Docker Compose 파일 NCP 서버로 전송 + run: scp -o StrictHostKeyChecking=no -P ${{ secrets.PROD_EC2_PORT }} docker-compose.yml ${{ secrets.PROD_EC2_USER }}@${{ secrets.PROD_EC2_HOST }}:./ + + - name: EC2 접속 후 이미지 다운로드 및 배포 + if: success() + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.PROD_EC2_HOST }} + username: ${{ secrets.PROD_EC2_USER }} + key: ${{ secrets.PROD_EC2_SSH_KEY }} + port: ${{ secrets.PROD_EC2_PORT }} + script: | + export DOCKER_CONTAINER_REGISTRY=${{ secrets.DOCKERHUB_USERNAME }} + export REDIS_PASSWORD='${{ secrets.PROD_REDIS_PASSWORD }}' + export GITHUB_SHA=${{ github.sha }} + sudo chmod +x ./deploy.sh + ./deploy.sh diff --git a/.gitignore b/.gitignore index 52709b9..23f4035 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,6 @@ out/ .kotlin ### YAML ### -application.yml \ No newline at end of file +application.yml +#src/main/resources/* +src/main/resources/firebase/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e00bc4d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM openjdk:17.0.1-jdk-slim +WORKDIR /app +COPY ./build/libs/backend-0.0.1-SNAPSHOT.jar /app/backend.jar +EXPOSE 8080 +ENTRYPOINT ["java"] +CMD ["-jar", "backend.jar"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bfc0d5 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# 큐-첵 (Ku-check): 큐시즘의 모든 일을 한큐에 체크하다, 큐첵 +> 🔗 **서비스 링크**: [`https://ku-check.vercel.app`](https://ku-check.vercel.app) +큐첵 소개 + +## 🎨 서비스 설명 +- 큐첵은 큐시즘 운영에 흩어져 있던 출결, 상벌점, 공지, 불참사유서 제출을 하나로 통합해 학회 운영을 더 정확하고 간편하게 만드는 전용 관리 서비스입니다. +- 운영진은 반복적인 수기 행정을 줄일 수 있고, 학회원은 내 활동 현황과 이번 주 핵심 정보를 한 곳에서 확인하며 불참사유서까지 앱에서 바로 제출할 수 있습니다. + +## 💻 Backend Members +| **김영록** | **김민지** | +|:-------------------------------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------------------------------------------:| +| | | +| `backend` | `backend` | + +## 📜 API 명세서 +[큐첵 개발 서버 API 명세서](https://dev.ku-check.o-r.kr/swagger-ui/index.html) + +## 🗃️ ERD +Ku-Check (1) (1) + +## 🛠️ 기술 스택 +| 기술 스택 | 사용 이유 | +|----------|-----------| +| **Spring Kotlin** | Kotlin은 간결하고 읽기 쉬운 문법을 제공하여 개발 생산성이 높아진다고 판단해 사용했습니다.
Java 기반 기술 스택과 완벽히 호환되어 러닝커브가 적다고 생각했습니다.
Null-safety 덕분에 런타임 오류(NPE)를 줄일 수 있어 선택했습니다. | +| **Spring Data JPA** | SQL을 직접 작성하지 않고 객체지향적인 방식으로 DB를 다루기 위해 JPA를 사용하였으며, Spring 환경에서 이를 쉽게 활용할 수 있도록 지원하는 Spring Data JPA를 선택했습니다. | +| **AWS EC2** | 서비스 배포 서버로 사용했습니다. | +| **AWS S3** | 큐픽 증빙사진, 불참증명서 파일 등을 저장하기 위한 파일 저장소로 사용했습니다. | +| **AWS RDS** | 타 클라우드 대비 저렴하고, VPC·보안 그룹과 통합되어 안전한 접근 제어가 가능하여 사용했습니다. | +| **Docker** | 개발 및 배포 환경을 컨테이너화하여 일관된 환경을 유지하기 위해 사용했습니다. | +| **GitHub Actions** | GitHub 기반으로 CI 환경을 일원화하여 자동화된 빌드 및 테스트 환경을 구축하기 위해 사용했습니다. | +| **MySQL 8.x** | MySQL 5 대비 향상된 성능, 강화된 보안 기능, 공간 데이터 처리 기능을 제공하여 사용했습니다. | +| **Redis** | TTL을 제공해 토큰과 같은 세션 정보를 유효시간 기반으로 관리할 수 있습니다.
메모리 기반 저장소로 DB 대비 빠른 속도를 제공하여 로그인 등 세션 관리에 적합합니다. | +| **JUnit** | JUnit Jupiter/Platform/Vintage 등 모듈형 구성 덕분에 유연하고 확장 가능한 테스트 환경을 제공하여 사용했습니다. | +| **AssertJ** | 외부 의존성을 모킹해 특정 단위만 독립적으로 테스트할 수 있습니다.
호출 검증, 다양한 입력 조건 설정 등 정교한 테스트 시나리오 구현이 가능하여 선택했습니다. | +| **Swagger** | 클라이언트–서버 간 API 명세서로 활용하기 위해 사용했습니다. | + + +## 💬 Commit Convention +> e.g. feat: 카카오 로그인 구현 #1 + +| Type | 내용 | +|------------| --------------------------------- | +| `feat` | 새로운 기능 추가 | +| `fix` | 버그 수정 | +| `hotfix` | 서비스 장애 등 긴급 이슈 수정 | +| `test` | 테스트 코드 추가 및 수정, 삭제 | +| `refactor` | 코드 리팩토링 | +| `deploy` | 배포 관련 작업 (CI/CD, 서버 설정, 배포 스크립트 등) | +| `setting` | 개발 환경 세팅| diff --git a/build.gradle.kts b/build.gradle.kts index d037b4f..9c57580 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25" + kotlin("plugin.serialization") version "2.1.0" id("org.springframework.boot") version "3.5.6" id("io.spring.dependency-management") version "1.1.7" kotlin("plugin.jpa") version "1.9.25" @@ -24,11 +25,41 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.jetbrains.kotlin:kotlin-reflect") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // swagger + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0") + // h2 + runtimeOnly("com.h2database:h2") + // jwt + implementation("io.jsonwebtoken:jjwt-api:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5") + // redis + implementation("org.springframework.boot:spring-boot-starter-data-redis") + //s3 + implementation("software.amazon.awssdk:s3:2.25.34") + implementation("software.amazon.awssdk:auth:2.25.34") + implementation("software.amazon.awssdk:regions:2.25.34") + implementation("software.amazon.awssdk:url-connection-client:2.25.30") + //serializable + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + //okhttp3 + implementation("com.squareup.okhttp3:okhttp:4.12.0") + //google auth + implementation("com.google.auth:google-auth-library-oauth2-http:1.33.1") + //test + testImplementation("io.mockk:mockk:1.13.5") + //aws secretmanager + implementation(platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.1.0")) + implementation("io.awspring.cloud:spring-cloud-aws-starter-secrets-manager") + //apple auth + implementation("com.nimbusds:nimbus-jose-jwt:9.40") } kotlin { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..694c02d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3.8" + +services: + server: + image: "${DOCKER_CONTAINER_REGISTRY}/backend:${GITHUB_SHA}" + container_name: onku + environment: + TZ: Asia/Seoul + ports: + - '8080:8080' + depends_on: + - redis + networks: + - onku-network + redis: + image: redis:6.0.9 + container_name: onku-redis + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD} + ports: + - '6379:6379' + volumes: + - redis-data:/data + networks: + - onku-network + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"] + +volumes: + redis-data: + +networks: + onku-network: + driver: bridge \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt new file mode 100644 index 0000000..eccbc94 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt @@ -0,0 +1,107 @@ +package onku.backend.domain.absence + +import jakarta.persistence.* +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.absence.enums.AbsenceApprovedType +import onku.backend.domain.absence.enums.AbsenceSubmitType +import onku.backend.domain.member.Member +import onku.backend.domain.session.Session +import onku.backend.global.entity.BaseEntity +import java.time.LocalDateTime + +@Entity +@Table( + name = "absence_report", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_absence_member_session", + columnNames = ["member_id", "session_id"] + ) + ] +) +class AbsenceReport( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "absence_report_id") + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id") + var session : Session, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + val member : Member, + + @Column(name = "url") + var url : String, + + @Column(name = "submit_type") + @Enumerated(EnumType.STRING) // 사용자가 제출한 사유서 type + var submitType : AbsenceSubmitType = AbsenceSubmitType.ABSENT, + + @Column(name = "approved_type") + @Enumerated(EnumType.STRING) // 운영진이 승인한 사유서 type + var approvedType: AbsenceApprovedType = AbsenceApprovedType.ABSENT, + + @Column(name = "reason") + var reason : String, + + @Column(name = "approval") + @Enumerated(EnumType.STRING) + var approval : AbsenceReportApproval, + + @Column(name = "lateDateTime") + var lateDateTime: LocalDateTime?, + + @Column(name = "leaveDateTime") + var leaveDateTime: LocalDateTime? +) : BaseEntity() { + companion object { + fun createAbsenceReport( + member: Member, + session : Session, + submitAbsenceReportRequest: SubmitAbsenceReportRequest, + fileKey : String, + ): AbsenceReport { + return AbsenceReport( + member = member, + session = session, + url = fileKey, + submitType = submitAbsenceReportRequest.submitType, + approvedType = AbsenceApprovedType.ABSENT, + reason = submitAbsenceReportRequest.reason, + approval = AbsenceReportApproval.SUBMIT, + leaveDateTime = submitAbsenceReportRequest.leaveDateTime, + lateDateTime = submitAbsenceReportRequest.lateDateTime + ) + } + } + + fun updateAbsenceReport( + submitAbsenceReportRequest: SubmitAbsenceReportRequest, + fileKey: String, + session: Session + ) { + this.session = session + this.reason = submitAbsenceReportRequest.reason + this.url = fileKey + this.submitType = submitAbsenceReportRequest.submitType + this.updatedAt = LocalDateTime.now() + this.leaveDateTime = submitAbsenceReportRequest.leaveDateTime + this.lateDateTime = submitAbsenceReportRequest.lateDateTime + } + + fun updateApprovedType( + approvedType: AbsenceApprovedType + ) { + this.approvedType = approvedType; + } + + fun updateApproval( + approval: AbsenceReportApproval + ) { + this.approval = approval + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/AbsenceReportErrorCode.kt b/src/main/kotlin/onku/backend/domain/absence/AbsenceReportErrorCode.kt new file mode 100644 index 0000000..8c42b52 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/AbsenceReportErrorCode.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.absence + +import onku.backend.global.exception.ApiErrorCode +import org.springframework.http.HttpStatus + +enum class AbsenceReportErrorCode( + override val errorCode: String, + override val message: String, + override val status: HttpStatus +) : ApiErrorCode { + ABSENCE_REPORT_NOT_FOUND("ABSENCE404", "해당하는 불참사유서를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/manager/AbsenceManagerController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/manager/AbsenceManagerController.kt new file mode 100644 index 0000000..7a45601 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/controller/manager/AbsenceManagerController.kt @@ -0,0 +1,32 @@ +package onku.backend.domain.absence.controller.manager + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.absence.dto.request.EstimateAbsenceReportRequest +import onku.backend.domain.absence.dto.response.GetMemberAbsenceReportResponse +import onku.backend.domain.absence.facade.AbsenceFacade +import onku.backend.global.response.SuccessResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/absence/manage") +@Tag(name = "[관리자용] 불참사유서 관련 API") +class AbsenceManagerController( + private val absenceFacade : AbsenceFacade +) { + @GetMapping("/{sessionId}") + @Operation(summary = "세션 별 불참 사유서 제출내역 조회", description = "세션 별로 학회원들이 낸 불참사유서 내역을 조회합니다.") + fun getMemberAbsenceReports(@PathVariable(name = "sessionId") sessionId: Long) + : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.getMemberAbsenceReport(sessionId))) + } + + @PatchMapping("/{absenceReportId}") + @Operation(summary = "불참 사유서 벌점 매기기", description = "제출한 불참 사유서에 대해서 벌점을 매긴다.") + fun estimateAbsenceReport(@PathVariable(name = "absenceReportId") absenceReportId : Long, + @RequestBody estimateAbsenceReportRequest: EstimateAbsenceReportRequest) + : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.estimateAbsenceReport(absenceReportId, estimateAbsenceReportRequest))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/user/AbsenceController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/user/AbsenceController.kt new file mode 100644 index 0000000..d36e9fb --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/controller/user/AbsenceController.kt @@ -0,0 +1,35 @@ +package onku.backend.domain.absence.controller.user + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.dto.response.GetMyAbsenceReportResponse +import onku.backend.domain.absence.facade.AbsenceFacade +import onku.backend.domain.member.Member +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.response.SuccessResponse +import onku.backend.global.s3.dto.GetPreSignedUrlDto +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/absence") +@Tag(name = "불참사유서 관련 API") +class AbsenceController( + private val absenceFacade : AbsenceFacade +) { + @PostMapping("") + @Operation(summary = "불참 사유서 제출", description = "불참 사유서 제출하는 API 입니다") + fun submitAbsenceReport(@CurrentMember member: Member, + @RequestBody @Valid submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.submitAbsenceReport(member, submitAbsenceReportRequest))) + } + + @GetMapping("") + @Operation(summary = "불참 사유서 제출내역 조회", description = "내가 낸 불참 사유서 제출내역을 조회합니다.") + fun getMyAbsenceReport(@CurrentMember member: Member) + : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.getMyAbsenceReport(member))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/dto/annotation/ValidAbsenceReport.kt b/src/main/kotlin/onku/backend/domain/absence/dto/annotation/ValidAbsenceReport.kt new file mode 100644 index 0000000..39fedd7 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/dto/annotation/ValidAbsenceReport.kt @@ -0,0 +1,15 @@ +package onku.backend.domain.absence.dto.annotation + +import jakarta.validation.Constraint +import jakarta.validation.Payload +import onku.backend.domain.absence.validator.AbsenceReportValidator +import kotlin.reflect.KClass + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [AbsenceReportValidator::class]) +annotation class ValidAbsenceReport( + val message: String = "Invalid absence report combination", + val groups: Array> = [], + val payload: Array> = [] +) diff --git a/src/main/kotlin/onku/backend/domain/absence/dto/request/EstimateAbsenceReportRequest.kt b/src/main/kotlin/onku/backend/domain/absence/dto/request/EstimateAbsenceReportRequest.kt new file mode 100644 index 0000000..daccf8e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/dto/request/EstimateAbsenceReportRequest.kt @@ -0,0 +1,9 @@ +package onku.backend.domain.absence.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.absence.enums.AbsenceApprovedType + +data class EstimateAbsenceReportRequest( + @Schema(description = "승인 타입", example = "EXCUSED / ABSENT / ABSENT_WITH_DOC / ABSENT_WITH_CAUSE / LATE / EARLY_LEAVE") + val approvedType: AbsenceApprovedType +) diff --git a/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt new file mode 100644 index 0000000..aa810ba --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt @@ -0,0 +1,16 @@ +package onku.backend.domain.absence.dto.request + +import jakarta.validation.constraints.NotNull +import onku.backend.domain.absence.dto.annotation.ValidAbsenceReport +import onku.backend.domain.absence.enums.AbsenceSubmitType +import java.time.LocalDateTime +@ValidAbsenceReport +data class SubmitAbsenceReportRequest( + val absenceReportId : Long?, + @field:NotNull val sessionId : Long, + val submitType : AbsenceSubmitType, + val reason : String, + val fileName : String?, + val lateDateTime : LocalDateTime?, + val leaveDateTime : LocalDateTime? +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMemberAbsenceReportResponse.kt b/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMemberAbsenceReportResponse.kt new file mode 100644 index 0000000..a5f9f8b --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMemberAbsenceReportResponse.kt @@ -0,0 +1,29 @@ +package onku.backend.domain.absence.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.absence.enums.AbsenceApprovedType +import onku.backend.domain.absence.enums.AbsenceSubmitType +import onku.backend.domain.member.enums.Part +import java.time.LocalDate +import java.time.LocalTime + +data class GetMemberAbsenceReportResponse( + @Schema(description = "학회원 이름", example = "홍길동") + val name : String, + @Schema(description = "파트", example = "BACKEND") + val part : Part, + @Schema(description = "불참사유서 id", example = "1") + val absenceReportId : Long, + @Schema(description = "제출일시", example = "2025-11-08") + val submitDate : LocalDate, + @Schema(description = "불참여부", example = "ABSENT / LATE / EARLY_LEAVE") + val submitType : AbsenceSubmitType, + @Schema(description = "시간", example = "13:00") + val time : LocalTime?, + @Schema(description = "사유", example = "코딩테스트 면접") + val reason : String, + @Schema(description = "증빙서류 presignedUrl", example = "https://s3~") + val url : String?, + @Schema(description = "벌점", example = "EXCUSED / ABSENT / ABSENT_WITH_DOC / ABSENT_WITH_CAUSE / LATE / EARLY_LEAVE / null") + val absenceApprovedType: AbsenceApprovedType? +) diff --git a/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMyAbsenceReportResponse.kt b/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMyAbsenceReportResponse.kt new file mode 100644 index 0000000..0e0a269 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMyAbsenceReportResponse.kt @@ -0,0 +1,15 @@ +package onku.backend.domain.absence.dto.response + +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.absence.enums.AbsenceSubmitType +import java.time.LocalDate +import java.time.LocalDateTime + +data class GetMyAbsenceReportResponse( + val absenceReportId : Long, + val absenceType : AbsenceSubmitType, + val absenceReportApproval : AbsenceReportApproval, + val submitDateTime : LocalDateTime, + val sessionTitle : String, + val sessionStartDate: LocalDate +) diff --git a/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt new file mode 100644 index 0000000..826d350 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt @@ -0,0 +1,26 @@ +package onku.backend.domain.absence.enums + +import io.swagger.v3.oas.annotations.media.Schema + +enum class AbsenceApprovedType( + @Schema(description = "해당 출석 상태의 점수") + val points: Int +) { + @Schema(description = "사유서 인정(공결): 0점") + EXCUSED(0), + + @Schema(description = "결석(미제출): -3점") + ABSENT(-3), + + @Schema(description = "결석(사유서 제출): -2점") + ABSENT_WITH_DOC(-2), + + @Schema(description = "기타 사유(사유서 제출): -1점") + ABSENT_WITH_CAUSE(-1), + + @Schema(description = "지각: -1점") + LATE(-1), + + @Schema(description = "조퇴: -1점") + EARLY_LEAVE(-1) +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceReportApproval.kt b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceReportApproval.kt new file mode 100644 index 0000000..74b95e5 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceReportApproval.kt @@ -0,0 +1,5 @@ +package onku.backend.domain.absence.enums + +enum class AbsenceReportApproval { + SUBMIT, APPROVED +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceSubmitType.kt b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceSubmitType.kt new file mode 100644 index 0000000..dda0a8c --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceSubmitType.kt @@ -0,0 +1,5 @@ +package onku.backend.domain.absence.enums + +enum class AbsenceSubmitType { + ABSENT, LATE, EARLY_LEAVE +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt new file mode 100644 index 0000000..0d51e1c --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -0,0 +1,114 @@ +package onku.backend.domain.absence.facade + +import onku.backend.domain.absence.dto.request.EstimateAbsenceReportRequest +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.dto.response.GetMemberAbsenceReportResponse +import onku.backend.domain.absence.dto.response.GetMyAbsenceReportResponse +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.absence.enums.AbsenceSubmitType +import onku.backend.domain.absence.service.AbsenceService +import onku.backend.domain.session.validator.SessionValidator +import onku.backend.domain.member.Member +import onku.backend.global.alarm.enums.AlarmTitleType +import onku.backend.domain.point.service.MemberPointHistoryService +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.service.SessionService +import onku.backend.global.alarm.AlarmMessage +import onku.backend.global.alarm.FCMService +import onku.backend.global.alarm.enums.AlarmEmojiType +import onku.backend.global.exception.CustomException +import onku.backend.global.s3.dto.GetPreSignedUrlDto +import onku.backend.global.s3.enums.FolderName +import onku.backend.global.s3.enums.UploadOption +import onku.backend.global.s3.service.S3Service +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Component +class AbsenceFacade( + private val absenceService : AbsenceService, + private val s3Service: S3Service, + private val sessionService: SessionService, + private val sessionValidator: SessionValidator, + private val fcmService: FCMService, + private val memberPointHistoryService: MemberPointHistoryService +) { + fun submitAbsenceReport(member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): GetPreSignedUrlDto { + val session = sessionService.getById(submitAbsenceReportRequest.sessionId) + //세션 검증 + when { + sessionValidator.isPastSession(session) -> { + throw CustomException(SessionErrorCode.SESSION_PAST) + } + !sessionValidator.isImminentSession(session) -> { + throw CustomException(SessionErrorCode.SESSION_PAST) + } + sessionValidator.isRestSession(session) -> { + throw CustomException(SessionErrorCode.INVALID_SESSION) + } + } + val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name, UploadOption.FILE) + absenceService.submitAbsenceReport(member, submitAbsenceReportRequest, preSignedUrlDto.key, session) + return GetPreSignedUrlDto(preSignedUrlDto.preSignedUrl) + } + + fun getMyAbsenceReport(member: Member): List { + val absenceReportList = absenceService.getMyAbsenceReports(member) + val responses = absenceReportList.map { v -> + GetMyAbsenceReportResponse( + absenceReportId = v.absenceReportId, + absenceType = v.absenceType, + absenceReportApproval = v.absenceReportApproval, + submitDateTime = v.submitDateTime, + sessionTitle = v.sessionTitle, + sessionStartDate = v.sessionStartDate + ) + } + return responses + } + + fun getMemberAbsenceReport(sessionId: Long): List { + val absenceReports = absenceService.getBySessionId(sessionId) + return absenceReports.map { report -> + val member = report.member + val memberProfile = member.memberProfile!! + val submitDate = report.updatedAt.toLocalDate() + val time = when (report.submitType) { + AbsenceSubmitType.LATE -> report.lateDateTime?.toLocalTime() + AbsenceSubmitType.EARLY_LEAVE -> report.leaveDateTime?.toLocalTime() + else -> null + } + val preSignedUrl = report.url + ?.takeIf { it.isNotBlank() } // null이 날 일은 없지만 혹시나 null이 날 경우를 대비(만약 그런 경우가 있으면 학회원 전체에 영향이 감) + ?.let { key -> s3Service.getGetS3Url(0L, key).preSignedUrl } + + GetMemberAbsenceReportResponse( + name = memberProfile.name ?: "UNKNOWN", + part = memberProfile.part, + absenceReportId = report.id!!, + submitDate = submitDate, + submitType = report.submitType, + time = time, + reason = report.reason, + url = preSignedUrl, + absenceApprovedType = when (report.approval) { + AbsenceReportApproval.SUBMIT -> null + AbsenceReportApproval.APPROVED -> report.approvedType + } + ) + } + } + + @Transactional + fun estimateAbsenceReport(absenceReportId: Long, estimateAbsenceReportRequest: EstimateAbsenceReportRequest): Boolean { + val absenceReport = absenceService.getById(absenceReportId) + absenceReport.updateApprovedType(estimateAbsenceReportRequest.approvedType) + absenceReport.updateApproval(AbsenceReportApproval.APPROVED) + memberPointHistoryService.upsertPointFromAbsenceReport(absenceReport) + val now = LocalDateTime.now() + fcmService.sendMessageTo(absenceReport.member, AlarmTitleType.ABSENCE_REPORT, AlarmEmojiType.WARNING, AlarmMessage.absenceReport(now.month.value, now.dayOfMonth, estimateAbsenceReportRequest.approvedType), null) + return true + } + +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt new file mode 100644 index 0000000..8e1b3d7 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -0,0 +1,51 @@ +package onku.backend.domain.absence.repository + +import onku.backend.domain.absence.AbsenceReport +import onku.backend.domain.absence.repository.projection.GetMyAbsenceReportView +import onku.backend.domain.member.Member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param + +interface AbsenceReportRepository : JpaRepository { + @Query( + """ + select + ar.id as absenceReportId, + ar.submitType as absenceSubmitType, + ar.approval as absenceReportApproval, + ar.createdAt as submitDateTime, + s.title as sessionTitle, + s.startDate as sessionStartDateTime + from AbsenceReport ar + join ar.session s + where ar.member = :member + """ + ) + fun findMyAbsenceReports( + @Param("member") member: Member + ): List + + @Query(""" + select ar from AbsenceReport ar + where ar.session.id = :sessionId and ar.member.id in :memberIds + """) + fun findReportsBySessionAndMembers( + @Param("sessionId") sessionId: Long, + @Param("memberIds") memberIds: Collection + ): List + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from AbsenceReport ar where ar.session.id = :sessionId") + fun deleteAllBySessionId(@Param("sessionId") sessionId: Long): Int + + @Query(""" + SELECT a + FROM AbsenceReport a + JOIN FETCH a.member m + JOIN FETCH m.memberProfile mp + WHERE a.session.id = :sessionId + """) + fun findAllBySessionId(@Param("sessionId") sessionId: Long): List +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/repository/projection/GetMyAbsenceReportView.kt b/src/main/kotlin/onku/backend/domain/absence/repository/projection/GetMyAbsenceReportView.kt new file mode 100644 index 0000000..9b0ac69 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/repository/projection/GetMyAbsenceReportView.kt @@ -0,0 +1,15 @@ +package onku.backend.domain.absence.repository.projection + +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.absence.enums.AbsenceSubmitType +import java.time.LocalDate +import java.time.LocalDateTime + +interface GetMyAbsenceReportView { + fun getAbsenceReportId(): Long + fun getAbsenceSubmitType(): AbsenceSubmitType + fun getAbsenceReportApproval(): AbsenceReportApproval + fun getSubmitDateTime(): LocalDateTime + fun getSessionTitle(): String + fun getSessionStartDateTime(): LocalDate +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt new file mode 100644 index 0000000..3aa058e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -0,0 +1,65 @@ +package onku.backend.domain.absence.service + +import onku.backend.domain.absence.AbsenceReport +import onku.backend.domain.absence.AbsenceReportErrorCode +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.dto.response.GetMyAbsenceReportResponse +import onku.backend.domain.absence.repository.AbsenceReportRepository +import onku.backend.domain.member.Member +import onku.backend.domain.session.Session +import onku.backend.global.exception.CustomException +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AbsenceService( + private val absenceReportRepository: AbsenceReportRepository, + +) { + @Transactional + fun submitAbsenceReport(member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest, fileKey : String, session : Session) { + val existingReport = submitAbsenceReportRequest.absenceReportId?.let { + absenceReportRepository.findByIdOrNull(it) + } + val report = if (existingReport != null) { + existingReport.updateAbsenceReport(submitAbsenceReportRequest, fileKey, session) + existingReport + } else { + AbsenceReport.createAbsenceReport( + member = member, + session = session, + submitAbsenceReportRequest = submitAbsenceReportRequest, + fileKey = fileKey + ) + } + absenceReportRepository.save(report) + } + + @Transactional(readOnly = true) + fun getMyAbsenceReports(member: Member): List { + val absenceReports = absenceReportRepository.findMyAbsenceReports(member) + return absenceReports.map { a -> + GetMyAbsenceReportResponse( + absenceReportId = a.getAbsenceReportId(), + absenceType = a.getAbsenceSubmitType(), + absenceReportApproval = a.getAbsenceReportApproval(), + submitDateTime = a.getSubmitDateTime(), + sessionTitle = a.getSessionTitle(), + sessionStartDate = a.getSessionStartDateTime() + ) + } + } + + @Transactional(readOnly = true) + fun getBySessionId(sessionId : Long) : List { + return absenceReportRepository.findAllBySessionId(sessionId) + } + + @Transactional(readOnly = true) + fun getById(absenceReportId: Long) : AbsenceReport { + return absenceReportRepository.findByIdOrNull(absenceReportId) ?: throw CustomException( + AbsenceReportErrorCode.ABSENCE_REPORT_NOT_FOUND + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceReportValidator.kt b/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceReportValidator.kt new file mode 100644 index 0000000..adfbc0e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceReportValidator.kt @@ -0,0 +1,55 @@ +package onku.backend.domain.absence.validator + +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import onku.backend.domain.absence.dto.annotation.ValidAbsenceReport +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.enums.AbsenceSubmitType + +class AbsenceReportValidator : ConstraintValidator { + override fun isValid( + value: SubmitAbsenceReportRequest?, + context: ConstraintValidatorContext + ): Boolean { + if (value == null) return true + + val type = value.submitType + val late = value.lateDateTime + val leave = value.leaveDateTime + + // 공통적으로 여러 에러메시지를 누적 가능하게 설정 + context.disableDefaultConstraintViolation() + + return when (type) { + AbsenceSubmitType.LATE -> { + if (leave != null) { + context.buildConstraintViolationWithTemplate("지각일 경우 leaveDateTime은 비워야 합니다.") + .addPropertyNode("leaveDateTime").addConstraintViolation() + false + } else true + } + AbsenceSubmitType.EARLY_LEAVE -> { + if (late != null) { + context.buildConstraintViolationWithTemplate("조퇴일 경우 lateDateTime은 비워야 합니다.") + .addPropertyNode("lateDateTime").addConstraintViolation() + false + } else true + } + AbsenceSubmitType.ABSENT -> { + var valid = true + if (late != null) { + context.buildConstraintViolationWithTemplate("결석일 경우 lateDateTime은 비워야 합니다.") + .addPropertyNode("lateDateTime").addConstraintViolation() + valid = false + } + if (leave != null) { + context.buildConstraintViolationWithTemplate("결석일 경우 leaveDateTime은 비워야 합니다.") + .addPropertyNode("leaveDateTime").addConstraintViolation() + valid = false + } + valid + } + else -> true + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt b/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt new file mode 100644 index 0000000..edfb786 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt @@ -0,0 +1,33 @@ +package onku.backend.domain.attendance + +import jakarta.persistence.* +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.global.entity.BaseEntity +import java.time.LocalDateTime + +@Entity +@Table( + name = "attendance", + uniqueConstraints = [UniqueConstraint( + name = "uk_attendance_session_member", + columnNames = ["session_id", "member_id"] + )] +) +class Attendance( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "attendance_id") + val id: Long? = null, + + @Column(name = "session_id", nullable = false) + val sessionId: Long, + + @Column(name = "member_id", nullable = false) + val memberId: Long, + + @Column(name = "attendance_time", nullable = false) + var attendanceTime: LocalDateTime, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + var status: AttendancePointType +) : BaseEntity() diff --git a/src/main/kotlin/onku/backend/domain/attendance/AttendanceErrorCode.kt b/src/main/kotlin/onku/backend/domain/attendance/AttendanceErrorCode.kt new file mode 100644 index 0000000..a357b2e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/AttendanceErrorCode.kt @@ -0,0 +1,16 @@ +package onku.backend.domain.attendance + +import onku.backend.global.exception.ApiErrorCode +import org.springframework.http.HttpStatus + +enum class AttendanceErrorCode( + override val errorCode: String, + override val message: String, + override val status: HttpStatus +) : ApiErrorCode { + ATTENDANCE_ALREADY_RECORDED("ATT001", "이미 해당 세션에 출석이 완료된 사용자입니다.", HttpStatus.CONFLICT), + TOKEN_INVALID("ATT002", "유효하지 않거나 이미 사용된 토큰입니다.", HttpStatus.UNAUTHORIZED), + SESSION_NOT_OPEN("ATT003", "현재 스캔 가능한 세션이 없습니다.", HttpStatus.BAD_REQUEST), + ATTENDANCE_NOT_FOUND("ATT004", "해당 출석 내역을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + INVALID_MEMBER_FOR_ATTENDANCE("ATT005", "해당 출석 내역의 사용자와 요청된 memberId가 일치하지 않습니다.", HttpStatus.BAD_REQUEST), +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/AttendancePolicy.kt b/src/main/kotlin/onku/backend/domain/attendance/AttendancePolicy.kt new file mode 100644 index 0000000..7d04cd8 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/AttendancePolicy.kt @@ -0,0 +1,15 @@ +package onku.backend.domain.attendance + +object AttendancePolicy { + // 토큰/세션 운영 정책 + const val TOKEN_TTL_SECONDS: Long = 15L // 출석 토큰 TTL + const val OPEN_GRACE_MINUTES: Long = 30L // 세션 오픈 허용 범위(+30분) + + // 지각/결석 정책 + const val LATE_WINDOW_MINUTES: Long = 20L // 지각 허용 20분 + const val SAFETY_OFFSET_MINUTES: Long = 1L + + // 결석 시작 경계 (= 21분) + val ABSENT_START_MINUTES: Long + get() = LATE_WINDOW_MINUTES + SAFETY_OFFSET_MINUTES +} diff --git a/src/main/kotlin/onku/backend/domain/attendance/controller/manager/AttendanceManagerController.kt b/src/main/kotlin/onku/backend/domain/attendance/controller/manager/AttendanceManagerController.kt new file mode 100644 index 0000000..a8fa9ca --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/controller/manager/AttendanceManagerController.kt @@ -0,0 +1,37 @@ +package onku.backend.domain.attendance.controller.manager + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.attendance.dto.* +import onku.backend.domain.attendance.service.AttendanceService +import onku.backend.domain.member.Member +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.response.SuccessResponse +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/attendance/manage") +@Tag(name = "[MANAGEMENT] 출석 API") +class AttendanceManagerController( + private val attendanceService: AttendanceService +) { + + @PostMapping("/scan") + @Operation( + summary = "출석 스캔 [MANAGEMENT]", + description = "열린 세션 자동 선택 → 토큰 검증 & 소비 (반환값에 금주 출석 요약 포함)" + ) + fun scan(@CurrentMember admin: Member, @RequestBody req: AttendanceRequest): SuccessResponse { + return SuccessResponse.ok(attendanceService.scanAndRecordBy(admin, req.token)) + } + + @GetMapping("/weekly-summary") + @Operation( + summary = "금주 출석 요약 조회 [MANAGEMENT]", + description = "이번 주 기간 내 출석/조퇴/지각/결석 인원 반환" + ) + fun getThisWeekSummary(): SuccessResponse { + val summary = attendanceService.getThisWeekSummary() + return SuccessResponse.ok(summary) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/controller/user/AttendanceMemberController.kt b/src/main/kotlin/onku/backend/domain/attendance/controller/user/AttendanceMemberController.kt new file mode 100644 index 0000000..a236694 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/controller/user/AttendanceMemberController.kt @@ -0,0 +1,43 @@ +package onku.backend.domain.attendance.controller.user + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.attendance.dto.* +import onku.backend.domain.attendance.facade.AttendanceFacade +import onku.backend.domain.attendance.service.AttendanceService +import onku.backend.domain.member.Member +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.response.SuccessResponse +import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/attendance") +@Tag(name = "출석 API") +class AttendanceMemberController( + private val attendanceService: AttendanceService, + private val attendanceFacade: AttendanceFacade +) { + + @PostMapping("/token") + @Operation( + summary = "출석용 토큰 발급 [USER]", + description = "15초 유효 + 프로필(이름/파트/학교/프사) 포함" + ) + fun issueQrToken(@CurrentMember member: Member): ResponseEntity> { + val headers = HttpHeaders().apply { add(HttpHeaders.CACHE_CONTROL, "no-store") } + val body = attendanceFacade.issueTokenWithProfile(member) + return ResponseEntity.ok().headers(headers).body(SuccessResponse.ok(body)) + } + + @GetMapping("/availability") + @Operation( + summary = "지금 출석 가능 여부 확인 [USER]", + description = "열린 세션 존재 및 기존 출석 기록 유무를 확인해 가능 여부 반환" + ) + fun checkAvailability(@CurrentMember member: Member): SuccessResponse { + val body = attendanceService.checkAvailabilityFor(member) + return SuccessResponse.ok(body) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceAvailabilityResponse.kt b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceAvailabilityResponse.kt new file mode 100644 index 0000000..2a82714 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceAvailabilityResponse.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.attendance.dto + +import onku.backend.domain.attendance.enums.AttendanceAvailabilityReason + +data class AttendanceAvailabilityResponse( + val available: Boolean, // 출석 가능 여부 + val reason: AttendanceAvailabilityReason?, // 불가 사유 (가능하면 null) +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceRequest.kt b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceRequest.kt new file mode 100644 index 0000000..7170383 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceRequest.kt @@ -0,0 +1,5 @@ +package onku.backend.domain.attendance.dto + +data class AttendanceRequest( + val token: String +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt new file mode 100644 index 0000000..206a015 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt @@ -0,0 +1,13 @@ +package onku.backend.domain.attendance.dto + +import onku.backend.domain.attendance.enums.AttendancePointType +import java.time.LocalDateTime + +data class AttendanceResponse( + val memberId: Long, + val memberName: String, + val sessionId: Long, + val state: AttendancePointType, + val scannedAt: LocalDateTime, + val thisWeekSummary: WeeklyAttendanceSummary +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenCore.kt b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenCore.kt new file mode 100644 index 0000000..e953d39 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenCore.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.attendance.dto + +import java.time.LocalDateTime + +data class AttendanceTokenCore( + val token: String, + val expAt: LocalDateTime +) diff --git a/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenResponse.kt b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenResponse.kt new file mode 100644 index 0000000..a1a5506 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenResponse.kt @@ -0,0 +1,13 @@ +package onku.backend.domain.attendance.dto + +import onku.backend.domain.member.enums.Part +import java.time.LocalDateTime + +data class AttendanceTokenResponse( + val token: String, + val expAt: LocalDateTime, + val name: String, + val part: Part, + val school: String?, + val profileImageUrl: String? +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/dto/WeeklyAttendanceSummary.kt b/src/main/kotlin/onku/backend/domain/attendance/dto/WeeklyAttendanceSummary.kt new file mode 100644 index 0000000..4493723 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/dto/WeeklyAttendanceSummary.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.attendance.dto + +data class WeeklyAttendanceSummary( + val present: Long, // 출석 = PRESENT_HOLIDAY + PRESENT + val earlyLeave: Long, // 조퇴 = EARLY_LEAVE + val late: Long, // 지각 = LATE + val absent: Long // 결석 = EXCUSED + ABSENT + ABSENT_WITH_DOC +) { + val total: Long get() = present + earlyLeave + late + absent +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceAvailabilityReason.kt b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceAvailabilityReason.kt new file mode 100644 index 0000000..6bcc759 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceAvailabilityReason.kt @@ -0,0 +1,6 @@ +package onku.backend.domain.attendance.enums + +enum class AttendanceAvailabilityReason { + NO_OPEN_SESSION, // 열린 세션이 없음 + ALREADY_RECORDED // 해당 세션에 이미 출석 기록이 있음 +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendancePointType.kt b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendancePointType.kt new file mode 100644 index 0000000..c217cb3 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendancePointType.kt @@ -0,0 +1,33 @@ +package onku.backend.domain.attendance.enums + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "출석 상태") +enum class AttendancePointType( + @Schema(description = "해당 출석 상태의 점수") + val points: Int +) { + @Schema(description = "출석(공휴일 세션): +1점") + PRESENT_HOLIDAY(1), + + @Schema(description = "출석(정시): 0점") + PRESENT(0), + + @Schema(description = "사유서 인정(공결): 0점") + EXCUSED(0), + + @Schema(description = "결석(미제출): -3점") + ABSENT(-3), + + @Schema(description = "결석(사유서 제출): -2점") + ABSENT_WITH_DOC(-2), + + @Schema(description = "기타 사유(사유서 제출): -1점") + ABSENT_WITH_CAUSE(-1), + + @Schema(description = "지각: -1점") + LATE(-1), + + @Schema(description = "조퇴: -1점") + EARLY_LEAVE(-1) +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/facade/AttendanceFacade.kt b/src/main/kotlin/onku/backend/domain/attendance/facade/AttendanceFacade.kt new file mode 100644 index 0000000..41ffbb9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/facade/AttendanceFacade.kt @@ -0,0 +1,29 @@ +package onku.backend.domain.attendance.facade + +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import onku.backend.domain.attendance.dto.AttendanceTokenResponse +import onku.backend.domain.attendance.service.AttendanceService +import onku.backend.domain.member.Member +import onku.backend.domain.member.service.MemberProfileService + +@Component +class AttendanceFacade( + private val attendanceService: AttendanceService, + private val memberProfileService: MemberProfileService +) { + @Transactional(readOnly = true) + fun issueTokenWithProfile(member: Member): AttendanceTokenResponse { + val core = attendanceService.issueAttendanceTokenFor(member) + val basics = memberProfileService.getProfileBasics(member) + + return AttendanceTokenResponse( + token = core.token, + expAt = core.expAt, + name = basics.name, + part = basics.part, + school = basics.school, + profileImageUrl = basics.profileImageUrl + ) + } +} diff --git a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeBootTrigger.kt b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeBootTrigger.kt new file mode 100644 index 0000000..4569a2d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeBootTrigger.kt @@ -0,0 +1,37 @@ +package onku.backend.domain.attendance.finalize + +import onku.backend.domain.attendance.AttendancePolicy +import onku.backend.domain.attendance.service.AttendanceFinalizeService +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.util.SessionTimeUtil +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.LocalDateTime + +@Component +class FinalizeBootTrigger( + private val sessionRepository: SessionRepository, + private val finalizeScheduler: FinalizeScheduler, + private val attendanceFinalizeService: AttendanceFinalizeService, + private val clock: Clock +) { + @EventListener(org.springframework.boot.context.event.ApplicationReadyEvent::class) // 서버 실행 직후 1회 실행 + fun rebuild() { + val now = LocalDateTime.now(clock) + val pivot = now.minusMinutes(AttendancePolicy.ABSENT_START_MINUTES) + + // 이미 결석 판정 시각을 지난 세션 → 즉시 finalize + sessionRepository.findFinalizeDue(pivot.toLocalDate(), pivot.toLocalTime()).forEach { s -> + runCatching { attendanceFinalizeService.finalizeSession(s.id!!) } + } + + // 아직 결석 판정 시각이 지나지 않은 세션 → 경계 시각으로 재예약 + sessionRepository.findUnfinalizedAfter(pivot.toLocalDate(), pivot.toLocalTime()).forEach { s -> + runCatching { + val runAt = SessionTimeUtil.absentBoundary(s, AttendancePolicy.ABSENT_START_MINUTES) + finalizeScheduler.schedule(s.id!!, runAt) + } + } + } +} diff --git a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEvent.kt b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEvent.kt new file mode 100644 index 0000000..2d02a6e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEvent.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.attendance.finalize + +import java.time.LocalDateTime + +/** + * - sessionId: 마감할 세션 PK + * - runAt : 결석 판정 경계 시각(= 이때 finalize 실행) + */ +data class FinalizeEvent( + val sessionId: Long, + val runAt: LocalDateTime +) diff --git a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEventTrigger.kt b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEventTrigger.kt new file mode 100644 index 0000000..a217d3f --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEventTrigger.kt @@ -0,0 +1,27 @@ +package onku.backend.domain.attendance.finalize + +import onku.backend.domain.attendance.service.AttendanceFinalizeService +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.LocalDateTime + +@Component +class FinalizeEventTrigger( + private val finalizeScheduler: FinalizeScheduler, + private val clock: Clock, + private val attendanceFinalizeService: AttendanceFinalizeService +) { + @org.springframework.transaction.event.TransactionalEventListener( + classes = [FinalizeEvent::class], + phase = org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT + ) + fun onSessionFinalizeSchedule(event: FinalizeEvent) { + val now = LocalDateTime.now(clock) + + if (!event.runAt.isAfter(now)) { + runCatching { attendanceFinalizeService.finalizeSession(event.sessionId) } + return + } + finalizeScheduler.schedule(event.sessionId, event.runAt) + } +} diff --git a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeScheduler.kt b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeScheduler.kt new file mode 100644 index 0000000..6804ca0 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeScheduler.kt @@ -0,0 +1,29 @@ +package onku.backend.domain.attendance.finalize + +import onku.backend.domain.attendance.service.AttendanceFinalizeService +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.scheduling.TaskScheduler +import org.springframework.stereotype.Service +import java.time.Clock +import java.time.LocalDateTime + +@Service +class FinalizeScheduler( + @Qualifier("AttendanceTaskScheduler") + private val taskScheduler: TaskScheduler, + private val finalizeService: AttendanceFinalizeService, + private val clock: Clock +) { + private val registry = java.util.concurrent.ConcurrentHashMap>() + + fun schedule(sessionId: Long, runAt: LocalDateTime) { + registry.remove(sessionId)?.cancel(false) // 기존 예약이 있으면 취소 + + val instant = runAt.atZone(clock.zone).toInstant() + val future = taskScheduler.schedule( + { finalizeService.finalizeSession(sessionId) }, + instant + ) + if (future != null) registry[sessionId] = future + } +} diff --git a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt new file mode 100644 index 0000000..884e014 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt @@ -0,0 +1,66 @@ +package onku.backend.domain.attendance.repository + +import onku.backend.domain.attendance.Attendance +import onku.backend.domain.attendance.enums.AttendancePointType +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.query.Param +import java.time.LocalDate +import java.time.LocalDateTime + +interface StatusCountProjection { + fun getStatus(): AttendancePointType + fun getCnt(): Long +} + +interface AttendanceRepository : CrudRepository { + + fun existsBySessionIdAndMemberId(sessionId: Long, memberId: Long): Boolean + + @Modifying + @Query( + value = """ + INSERT INTO attendance + (session_id, member_id, status, attendance_time, created_at, updated_at) + VALUES + (:sessionId, :memberId, :status, :attendanceTime, :createdAt, :updatedAt) + """, + nativeQuery = true + ) + fun insertOnly( + @Param("sessionId") sessionId: Long, + @Param("memberId") memberId: Long, + @Param("status") status: String, + @Param("attendanceTime") attendanceTime: LocalDateTime, + @Param("createdAt") createdAt: LocalDateTime, + @Param("updatedAt") updatedAt: LocalDateTime + ): Int + + fun findByMemberIdInAndAttendanceTimeBetween( + memberIds: Collection, + start: LocalDateTime, + end: LocalDateTime + ): List + + @Query(""" + select a.memberId from Attendance a + where a.sessionId = :sessionId + """) + fun findMemberIdsBySessionId(@Param("sessionId") sessionId: Long): List + + @Query(""" + select a.status as status, count(a) as cnt + from Attendance a + where function('date', a.attendanceTime) between :startDate and :endDate + group by a.status + """) + fun countGroupedByStatusBetweenDates( + @Param("startDate") startDate: LocalDate, + @Param("endDate") endDate: LocalDate + ): List + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from Attendance a where a.sessionId = :sessionId") + fun deleteAllBySessionId(@Param("sessionId") sessionId: Long): Int +} diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt new file mode 100644 index 0000000..df72bca --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt @@ -0,0 +1,91 @@ +package onku.backend.domain.attendance.service + +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import onku.backend.domain.absence.repository.AbsenceReportRepository +import onku.backend.domain.attendance.AttendancePolicy +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.attendance.util.AbsenceReportToAttendancePointMapper +import onku.backend.domain.member.Member +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.point.MemberPointHistory +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.session.Session +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.util.SessionTimeUtil +import onku.backend.global.exception.CustomException +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDateTime + +@Service +class AttendanceFinalizeService( + private val sessionRepository: SessionRepository, + private val attendanceRepository: AttendanceRepository, + private val absenceReportRepository: AbsenceReportRepository, + private val memberRepository: MemberRepository, + private val memberPointHistoryRepository: MemberPointHistoryRepository, + @PersistenceContext private val em: EntityManager, + private val clock: Clock +) { + @Transactional + fun finalizeSession(sessionId: Long) { + val now = LocalDateTime.now(clock) + val session = sessionRepository.findById(sessionId) + .orElseThrow { CustomException(SessionErrorCode.SESSION_NOT_FOUND) } + if (session.attendanceFinalized) return + + val startDateTime = SessionTimeUtil.startDateTime(session) + val absentBoundary = startDateTime.plusMinutes(AttendancePolicy.ABSENT_START_MINUTES) + + val targetIds: Set = memberRepository.findApprovedMemberIds().toSet() + if (targetIds.isEmpty()) { markFinalized(session, now); return } + + val recordedIds = attendanceRepository.findMemberIdsBySessionId(sessionId).toHashSet() + val missing = targetIds - recordedIds + + if (missing.isNotEmpty()) { + val papers = absenceReportRepository.findReportsBySessionAndMembers(sessionId, missing) + .associateBy { it.member.id!! } + + missing.forEach { memberId -> + val report = papers[memberId] + val status: AttendancePointType = + report?.let { AbsenceReportToAttendancePointMapper.map(it.approval, it.approvedType) } + ?: AttendancePointType.ABSENT + + try { + val inserted = attendanceRepository.insertOnly( + sessionId = sessionId, + memberId = memberId, + status = status.name, + attendanceTime = absentBoundary, + createdAt = now, + updatedAt = now + ) + if (inserted > 0) { + val memberRef = em.getReference(Member::class.java, memberId) + val history = MemberPointHistory.ofAttendance( + member = memberRef, + status = status, + occurredAt = absentBoundary, + week = session.week + ) + memberPointHistoryRepository.save(history) + } + } catch (_: DataIntegrityViolationException) { + } + } + } + markFinalized(session, now) + } + + private fun markFinalized(session: Session, now: LocalDateTime) { + session.attendanceFinalized = true + session.attendanceFinalizedAt = now + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt new file mode 100644 index 0000000..1365d98 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -0,0 +1,186 @@ +package onku.backend.domain.attendance.service + +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import onku.backend.domain.attendance.AttendanceErrorCode +import onku.backend.domain.attendance.AttendancePolicy +import onku.backend.domain.attendance.dto.* +import onku.backend.domain.attendance.enums.AttendanceAvailabilityReason +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.member.Member +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.point.MemberPointHistory +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.util.SessionTimeUtil +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode +import onku.backend.global.redis.cache.AttendanceTokenCache +import onku.backend.global.time.TimeRangeUtil +import onku.backend.global.util.TokenGenerator +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDateTime + +@Service +class AttendanceService( + private val tokenCache: AttendanceTokenCache, + private val sessionRepository: SessionRepository, + private val attendanceRepository: AttendanceRepository, + private val memberProfileRepository: MemberProfileRepository, + private val memberPointHistoryRepository: MemberPointHistoryRepository, + private val tokenGenerator: TokenGenerator, + @PersistenceContext private val em: EntityManager, + private val clock: Clock +) { + @Transactional(readOnly = true) + fun issueAttendanceTokenFor(member: Member): AttendanceTokenCore { + val now = LocalDateTime.now(clock) + val expAt = now.plusSeconds(AttendancePolicy.TOKEN_TTL_SECONDS) + val token = tokenGenerator.generateOpaqueToken() + + tokenCache.putAsActiveSingle( + member.id!!, + token, + now, + expAt, + AttendancePolicy.TOKEN_TTL_SECONDS + ) + return AttendanceTokenCore(token = token, expAt = expAt) + } + + private fun findOpenSession(now: LocalDateTime): Session? { + val startBound = now.plusMinutes(AttendancePolicy.OPEN_GRACE_MINUTES) + return sessionRepository.findOpenWindow(startBound, now).firstOrNull() + } + + @Transactional(readOnly = true) + fun getThisWeekSummary(): WeeklyAttendanceSummary { + val range = TimeRangeUtil.thisWeekRange() + val rows = attendanceRepository.countGroupedByStatusBetweenDates( + range.startOfWeek, range.endOfWeek + ) + + var present = 0L + var earlyLeave = 0L + var late = 0L + var absent = 0L + + rows.forEach { r -> + when (r.getStatus()) { + AttendancePointType.PRESENT, + AttendancePointType.PRESENT_HOLIDAY -> present += r.getCnt() + + AttendancePointType.EARLY_LEAVE -> earlyLeave += r.getCnt() + AttendancePointType.LATE -> late += r.getCnt() + + AttendancePointType.EXCUSED, + AttendancePointType.ABSENT, + AttendancePointType.ABSENT_WITH_DOC, + AttendancePointType.ABSENT_WITH_CAUSE -> absent += r.getCnt() + } + } + return WeeklyAttendanceSummary( + present = present, + earlyLeave = earlyLeave, + late = late, + absent = absent + ) + } + + @Transactional + fun scanAndRecordBy(admin: Member, token: String): AttendanceResponse { + val now = LocalDateTime.now(clock) + + val session = findOpenSession(now) + ?: throw CustomException(AttendanceErrorCode.SESSION_NOT_OPEN) + + val peek = tokenCache.peek(token) + ?: throw CustomException(AttendanceErrorCode.TOKEN_INVALID) + + val memberId = peek.memberId + val memberName = memberProfileRepository.findById(memberId).orElse(null)?.name ?: "Unknown" + + if (attendanceRepository.existsBySessionIdAndMemberId(session.id!!, memberId)) { + throw CustomException(AttendanceErrorCode.ATTENDANCE_ALREADY_RECORDED) + } + + tokenCache.consumeToken(token) ?: throw CustomException(ErrorCode.UNAUTHORIZED) + + val startDateTime = SessionTimeUtil.startDateTime(session) + val lateThreshold = startDateTime.plusMinutes(AttendancePolicy.LATE_WINDOW_MINUTES) + + val state = when { + now.isAfter(lateThreshold) -> AttendancePointType.ABSENT + !now.isBefore(startDateTime) -> AttendancePointType.LATE + else -> if (session.isHoliday) { + AttendancePointType.PRESENT_HOLIDAY + } else { + AttendancePointType.PRESENT + } + } + + try { + attendanceRepository.insertOnly( + sessionId = session.id!!, + memberId = memberId, + status = state.name, + attendanceTime = now, + createdAt = now, + updatedAt = now + ) + + val memberRef = em.getReference(Member::class.java, memberId) + val week: Long = session.week + val history = MemberPointHistory.ofAttendance( + member = memberRef, + status = state, + occurredAt = now, + week = week, + time = now.toLocalTime() + ) + memberPointHistoryRepository.save(history) + + } catch (e: DataIntegrityViolationException) { + throw CustomException(AttendanceErrorCode.ATTENDANCE_ALREADY_RECORDED) + } + val weeklySummary = getThisWeekSummary() + + return AttendanceResponse( + memberId = memberId, + memberName = memberName, + sessionId = session.id!!, + state = state, + scannedAt = now, + thisWeekSummary = weeklySummary + ) + } + + @Transactional(readOnly = true) + fun checkAvailabilityFor(member: Member): AttendanceAvailabilityResponse { + val now = LocalDateTime.now(clock) + + val session = findOpenSession(now) + ?: return AttendanceAvailabilityResponse( + available = false, + reason = AttendanceAvailabilityReason.NO_OPEN_SESSION, + ) + + val already = attendanceRepository.existsBySessionIdAndMemberId(session.id!!, member.id!!) + if (already) { + return AttendanceAvailabilityResponse( + available = false, + reason = AttendanceAvailabilityReason.ALREADY_RECORDED, + ) + } + + return AttendanceAvailabilityResponse( + available = true, + reason = null, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/util/AbsenceReportToAttendancePointMapper.kt b/src/main/kotlin/onku/backend/domain/attendance/util/AbsenceReportToAttendancePointMapper.kt new file mode 100644 index 0000000..a352871 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/util/AbsenceReportToAttendancePointMapper.kt @@ -0,0 +1,40 @@ +package onku.backend.domain.attendance.util + +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.absence.enums.AbsenceApprovedType +import onku.backend.domain.attendance.enums.AttendancePointType + +/** + * AbsenceReport의 승인 상태(approval)와 유형(submitType)을 + * AttendancePointType으로 매핑한다. + * + * 사용 예) + * val type = AbsenceReportToAttendancePointMapper.map(approval, submitType) // AttendancePointType + * val points = type.points // Int (각 상태의 점수) + */ +object AbsenceReportToAttendancePointMapper { + + /** + * - 기획 요구사항 정리 + * - SUBMIT(제출만)인 경우: ABSENT로 간주 (-3점) + * - APPROVED(승인)인 경우: status에 따라 매핑 + * - 그 외: ABSENT + */ + fun map( + approval: AbsenceReportApproval, + status: AbsenceApprovedType + ): AttendancePointType { + return when (approval) { + AbsenceReportApproval.SUBMIT -> AttendancePointType.ABSENT + AbsenceReportApproval.APPROVED -> when (status) { + AbsenceApprovedType.EXCUSED -> AttendancePointType.EXCUSED + AbsenceApprovedType.ABSENT -> AttendancePointType.ABSENT + AbsenceApprovedType.ABSENT_WITH_DOC -> AttendancePointType.ABSENT_WITH_DOC + AbsenceApprovedType.ABSENT_WITH_CAUSE -> AttendancePointType.ABSENT_WITH_CAUSE + AbsenceApprovedType.LATE -> AttendancePointType.LATE + AbsenceApprovedType.EARLY_LEAVE -> AttendancePointType.EARLY_LEAVE + } + else -> AttendancePointType.ABSENT + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt new file mode 100644 index 0000000..39ee667 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt @@ -0,0 +1,85 @@ +package onku.backend.domain.kupick + +import jakarta.persistence.* +import onku.backend.domain.member.Member +import onku.backend.global.entity.BaseEntity +import java.time.LocalDateTime + +@Entity +@Table(name = "kupick") +class Kupick( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "kupick_id") + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + val member : Member, + + @Column(name = "submit_date") + var submitDate: LocalDateTime? = null, + + @Column(name = "application_image_url") + var applicationImageUrl: String, + + @Column(name = "application_date") + var applicationDate : LocalDateTime? = null, + + @Column(name = "view_image_url") + var viewImageUrl : String? = null, + + @Column(name = "view_date") + var viewDate : LocalDateTime? = null, + + @Column(name = "approval") + var approval : Boolean = false, + + @Column(name = "isApprovalCheck") + var isApprovalCheck : Boolean = false + + ) : BaseEntity() { + companion object { + fun createApplication( + member: Member, + applicationImageUrl: String, + applicationDate: LocalDateTime? + ): Kupick { + return Kupick( + member = member, + applicationImageUrl = applicationImageUrl, + applicationDate = applicationDate, + submitDate = applicationDate + ) + } + + fun createKupick(member: Member, nowDate: LocalDateTime): Kupick { + return Kupick( + member = member, + submitDate = nowDate, + applicationImageUrl = "null", + applicationDate = nowDate, + viewImageUrl = null, + viewDate = null, + approval = false + ) + } + } + + fun submitView(viewImageUrl: String, nowDate: LocalDateTime) { + this.viewImageUrl = viewImageUrl + this.viewDate = nowDate + this.submitDate = nowDate + } + + fun updateApplication(newUrl: String, newDate: LocalDateTime) { + this.applicationImageUrl = newUrl + this.applicationDate = newDate + this.submitDate = newDate + } + + fun updateApproval(approval: Boolean) { + this.approval = approval + this.isApprovalCheck = true + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt b/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt new file mode 100644 index 0000000..f0bb476 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt @@ -0,0 +1,15 @@ +package onku.backend.domain.kupick + +import onku.backend.global.exception.ApiErrorCode +import org.springframework.http.HttpStatus + +enum class KupickErrorCode( + override val errorCode: String, + override val message: String, + override val status: HttpStatus +) : ApiErrorCode { + KUPICK_SAVE_FAILED("KUPICK500_SAVE", "큐픽 저장에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + KUPICK_APPLICATION_FIRST("kupick001", "큐픽 신청부터 진행해주세요", HttpStatus.BAD_REQUEST), + KUPICK_NOT_FOUND("kupick002", "해당 큐픽 객체를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + KUPICK_NOT_UPDATE("kupick003", "이미 승인된 큐픽은 수정할 수 없습니다.", HttpStatus.BAD_REQUEST) +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt b/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt new file mode 100644 index 0000000..48f225f --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt @@ -0,0 +1,40 @@ +package onku.backend.domain.kupick.controller.manager + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.kupick.dto.request.KupickApprovalRequest +import onku.backend.domain.kupick.dto.response.ShowUpdateResponseDto +import onku.backend.domain.kupick.facade.KupickFacade +import onku.backend.global.response.SuccessResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/kupick/manage") +@Tag(name = "[MANAGEMENT] 큐픽 API", description = "큐픽 관련 CRUD API") +class KupickManagerController( + private val kupickFacade: KupickFacade +) { + @GetMapping("/update") + @Operation( + summary = "큐픽 신청 현황", + description = "학회원들의 큐픽 서류 신청 현황 조회" + ) + fun submitApplication( + year : Int, + month : Int, + ) : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.showUpdate(year, month))) + } + + @PostMapping("/approval") + @Operation( + summary = "큐픽 승인/미승인 처리", + description = "학회원들 큐픽 신청 승인 및 미승인 처리" + ) + fun decideKupickApproval( + kupickApprovalRequest: KupickApprovalRequest + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.decideApproval(kupickApprovalRequest))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt b/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt new file mode 100644 index 0000000..55a2754 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt @@ -0,0 +1,55 @@ +package onku.backend.domain.kupick.controller.user + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.kupick.dto.response.ViewMyKupickResponseDto +import onku.backend.domain.kupick.facade.KupickFacade +import onku.backend.domain.member.Member +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.response.SuccessResponse +import onku.backend.global.s3.dto.GetUpdateAndDeleteUrlDto +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/kupick") +@Tag(name = "큐픽 API", description = "큐픽 관련 CRUD API") +class KupickController( + private val kupickFacade: KupickFacade +) { + @PostMapping("/application") + @Operation( + summary = "큐픽 신청 서류 제출", + description = "큐픽 신청용 서류 제출 signedUrl 반환" + ) + fun submitApplication( + @CurrentMember member : Member, fileName : String + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.submitApplication(member, fileName))) + } + + @PostMapping("/view") + @Operation( + summary = "큐픽 시청 서류 제출", + description = "큐픽 시청 증빙 서류 제출 signedUrl 반환" + ) + fun submitView( + @CurrentMember member: Member, fileName: String + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.submitView(member, fileName))) + } + + @GetMapping("/my") + @Operation( + summary = "큐픽 조회", + description = "내가 신청한 큐픽 내역 조회" + ) + fun viewMyKupick( + @CurrentMember member: Member + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.viewMyKupick(member))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/dto/KupickMemberInfo.kt b/src/main/kotlin/onku/backend/domain/kupick/dto/KupickMemberInfo.kt new file mode 100644 index 0000000..f71b664 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/dto/KupickMemberInfo.kt @@ -0,0 +1,9 @@ +package onku.backend.domain.kupick.dto + +import onku.backend.domain.member.Member +import java.time.LocalDateTime + +data class KupickMemberInfo( + val member: Member, + val submitDate: LocalDateTime? +) diff --git a/src/main/kotlin/onku/backend/domain/kupick/dto/request/KupickApprovalRequest.kt b/src/main/kotlin/onku/backend/domain/kupick/dto/request/KupickApprovalRequest.kt new file mode 100644 index 0000000..b6ef788 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/dto/request/KupickApprovalRequest.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.kupick.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class KupickApprovalRequest( + @Schema(description = "큐픽 ID", example = "1") + val kupickId : Long, + @Schema(description = "승인여부 설정", example = "true / false") + val approval : Boolean +) diff --git a/src/main/kotlin/onku/backend/domain/kupick/dto/response/ShowUpdateResponseDto.kt b/src/main/kotlin/onku/backend/domain/kupick/dto/response/ShowUpdateResponseDto.kt new file mode 100644 index 0000000..926234d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/dto/response/ShowUpdateResponseDto.kt @@ -0,0 +1,39 @@ +package onku.backend.domain.kupick.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.kupick.Kupick +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.enums.Part +import java.time.LocalDateTime + +data class ShowUpdateResponseDto( + @Schema(description = "학회원 이름", example = "홍길동") + val name : String?, + @Schema(description = "파트", example = "ACKEND, FRONTEND, DESIGN, PLANNING") + val part : Part, + @Schema(description = "큐픽id", example = "1") + val kupickId : Long?, + @Schema(description = "제출일시", example = "2025-07-15T14:00:00") + val submitDate : LocalDateTime?, + @Schema(description = "신청사진Url", example = "https://~") + val applicationUrl : String?, + @Schema(description = "시청사진Url", example = "https://~") + val viewUrl : String?, + @Schema(description = "승인여부", example = "true") + val approval : Boolean, + @Schema(description = "승인여부 선택 여부", example = "false") + val isApprovalCheck : Boolean + ) { + companion object { + fun of(memberProfile : MemberProfile, kupick : Kupick) = ShowUpdateResponseDto ( + memberProfile.name, + memberProfile.part, + kupick.id, + kupick.submitDate, + kupick.applicationImageUrl, + kupick.viewImageUrl, + kupick.approval, + kupick.isApprovalCheck + ) + } +} diff --git a/src/main/kotlin/onku/backend/domain/kupick/dto/response/ViewMyKupickResponseDto.kt b/src/main/kotlin/onku/backend/domain/kupick/dto/response/ViewMyKupickResponseDto.kt new file mode 100644 index 0000000..cc583c9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/dto/response/ViewMyKupickResponseDto.kt @@ -0,0 +1,23 @@ +package onku.backend.domain.kupick.dto.response + +import java.time.LocalDateTime + +data class ViewMyKupickResponseDto( + val applicationUrl: String?, + val applicationDateTime : LocalDateTime?, + val viewUrl: String?, + val viewDateTime : LocalDateTime? +) { + companion object { + fun of(applicationUrl: String?, + applicationDateTime: LocalDateTime?, + viewUrl: String?, + viewDateTime: LocalDateTime? + ) = ViewMyKupickResponseDto( + applicationUrl, + applicationDateTime, + viewUrl, + viewDateTime + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt new file mode 100644 index 0000000..9c72164 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -0,0 +1,108 @@ +package onku.backend.domain.kupick.facade + +import onku.backend.domain.kupick.dto.request.KupickApprovalRequest +import onku.backend.domain.kupick.dto.response.ShowUpdateResponseDto +import onku.backend.domain.kupick.dto.response.ViewMyKupickResponseDto +import onku.backend.domain.kupick.service.KupickService +import onku.backend.domain.member.Member +import onku.backend.global.alarm.enums.AlarmTitleType +import onku.backend.global.alarm.AlarmMessage +import onku.backend.global.alarm.FCMService +import onku.backend.global.alarm.enums.AlarmEmojiType +import onku.backend.global.s3.dto.GetUpdateAndDeleteUrlDto +import onku.backend.global.s3.enums.FolderName +import onku.backend.global.s3.enums.UploadOption +import onku.backend.global.s3.service.S3Service +import org.springframework.stereotype.Component + +@Component +class KupickFacade( + private val s3Service: S3Service, + private val kupickService: KupickService, + private val fcmService: FCMService +) { + fun submitApplication(member: Member, fileName: String): GetUpdateAndDeleteUrlDto { + val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_APPLICATION.name, UploadOption.IMAGE) + val oldDeletePreSignedUrl = kupickService + .submitApplication(member, signedUrlDto.key) + ?.let { oldKey -> s3Service.getDeleteS3Url(oldKey).preSignedUrl } + ?: "" + return GetUpdateAndDeleteUrlDto( + signedUrlDto.preSignedUrl, + oldDeletePreSignedUrl + ) + } + + fun submitView(member: Member, fileName: String): GetUpdateAndDeleteUrlDto { + val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_VIEW.name, UploadOption.IMAGE) + val oldDeletePreSignedUrl = kupickService + .submitView(member, signedUrlDto.key) + ?.let { oldKey -> s3Service.getDeleteS3Url(oldKey).preSignedUrl } + ?: "" + return GetUpdateAndDeleteUrlDto( + signedUrlDto.preSignedUrl, + oldDeletePreSignedUrl + ) + } + + fun viewMyKupick(member: Member): ViewMyKupickResponseDto { + val urls = kupickService.viewMyKupick(member) + val s3GetApplicationImageUrl = urls?.getApplicationImageUrl() + ?.let { s3Service.getGetS3Url(member.id!!, it).preSignedUrl } + val s3GetViewImageUrl = urls?.getViewImageUrl() + ?.let { s3Service.getGetS3Url(member.id!!, it).preSignedUrl } + return ViewMyKupickResponseDto.of( + s3GetApplicationImageUrl, + urls?.getApplicationDate(), + s3GetViewImageUrl, + urls?.getViewDate() + ) + } + + fun showUpdate(year : Int, month : Int): List { + val profiles = kupickService.findAllAsShowUpdateResponse(year, month) + val dtoList = profiles.map { p -> + val memberId = p.memberProfile.memberId!! + val profile = p.memberProfile + + val applicationUrl: String? = + p.kupick.applicationImageUrl + ?.takeIf { it.isNotBlank() } //여기서 만약 applicationImageUrl이 null값이 들어가면 에러가 남(전체 학회원 큐픽 조회에 영향을 끼침) 그래서 safe call 추가 + ?.let { key -> s3Service.getGetS3Url(memberId, key).preSignedUrl } + val viewUrl: String? = + p.kupick.viewImageUrl + ?.takeIf { it.isNotBlank() } + ?.let { key -> s3Service.getGetS3Url(memberId, key).preSignedUrl } + + ShowUpdateResponseDto( + name = profile.name, + part = profile.part, + kupickId = p.kupick.id, + submitDate = p.kupick.submitDate, + applicationUrl = applicationUrl, + viewUrl = viewUrl, + approval = p.kupick.approval, + isApprovalCheck = p.kupick.isApprovalCheck + ) + } + return dtoList + } + + fun decideApproval(kupickApprovalRequest: KupickApprovalRequest): Boolean { + kupickService.decideApproval(kupickApprovalRequest.kupickId, kupickApprovalRequest.approval) + val info = kupickService.findFcmInfo(kupickApprovalRequest.kupickId) + val member = info?.member + val submitMonth = info?.submitDate?.monthValue + fcmService.sendMessageTo( + member = member!!, + alarmTitleType = AlarmTitleType.KUPICK, + alarmEmojiType = when (kupickApprovalRequest.approval) { + true -> AlarmEmojiType.STAR + false -> AlarmEmojiType.WARNING + }, + body = AlarmMessage.kupick(submitMonth!!, kupickApprovalRequest.approval), + link = null + ) + return true + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt new file mode 100644 index 0000000..cea5ef9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt @@ -0,0 +1,83 @@ +package onku.backend.domain.kupick.repository + +import onku.backend.domain.kupick.Kupick +import onku.backend.domain.kupick.dto.KupickMemberInfo +import onku.backend.domain.kupick.repository.projection.KupickUrls +import onku.backend.domain.kupick.repository.projection.KupickWithProfile +import onku.backend.domain.member.Member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface KupickRepository : JpaRepository { + @Query(""" + SELECT k FROM Kupick k + WHERE k.member = :member + AND k.applicationDate >= :start + AND k.applicationDate < :end + """) + fun findThisMonthByMember( + member: Member, + start: LocalDateTime, + end: LocalDateTime + ): Kupick? + + fun findFirstByMemberAndApplicationDateBetween( + member: Member, + start: LocalDateTime, + end: LocalDateTime + ): Kupick? + + @Query(""" + SELECT k.applicationImageUrl AS applicationImageUrl, + k.viewImageUrl AS viewImageUrl, + k.applicationDate AS applicationDate, + k.viewDate AS viewDate + FROM Kupick k + WHERE k.member = :member + AND k.applicationDate >= :start + AND k.applicationDate < :end +""") + fun findUrlsForMemberInMonth( + @Param("member") member: Member, + @Param("start") startOfMonth: LocalDateTime, + @Param("end") startOfNextMonth: LocalDateTime + ): KupickUrls? + + @Query(""" + select k as kupick, mp as memberProfile + from Kupick k + join k.member m + left join MemberProfile mp on mp.member = m + where k.submitDate >= :start and k.submitDate < :end + """) + fun findAllWithProfile( + @Param("start") start: LocalDateTime, + @Param("end") end: LocalDateTime, + ): List + + @Query( + """ + SELECT k.member.id, FUNCTION('month', k.submitDate) + FROM Kupick k + WHERE k.submitDate IS NOT NULL + AND k.submitDate >= :start + AND k.submitDate < :end + AND k.member.id IN :memberIds + """ + ) + fun findMemberMonthParticipation( + memberIds: Collection, + start: LocalDateTime, + end: LocalDateTime + ): List> + + @Query(""" + SELECT new onku.backend.domain.kupick.dto.KupickMemberInfo(m, k.submitDate) + FROM Kupick k + JOIN k.member m + WHERE k.id = :kupickId +""") + fun findFcmInfoByKupickId(@Param("kupickId") kupickId: Long): KupickMemberInfo? +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/repository/projection/KupickUrls.kt b/src/main/kotlin/onku/backend/domain/kupick/repository/projection/KupickUrls.kt new file mode 100644 index 0000000..81c50e0 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/repository/projection/KupickUrls.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.kupick.repository.projection + +import java.time.LocalDateTime + +interface KupickUrls { + fun getApplicationImageUrl(): String? + fun getViewImageUrl(): String? + fun getApplicationDate(): LocalDateTime? + fun getViewDate(): LocalDateTime? +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/repository/projection/KupickWithProfile.kt b/src/main/kotlin/onku/backend/domain/kupick/repository/projection/KupickWithProfile.kt new file mode 100644 index 0000000..24e224e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/repository/projection/KupickWithProfile.kt @@ -0,0 +1,9 @@ +package onku.backend.domain.kupick.repository.projection + +import onku.backend.domain.kupick.Kupick +import onku.backend.domain.member.MemberProfile + +interface KupickWithProfile { + val kupick: Kupick + val memberProfile: MemberProfile +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt new file mode 100644 index 0000000..65f845b --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -0,0 +1,89 @@ +package onku.backend.domain.kupick.service + +import onku.backend.domain.kupick.Kupick +import onku.backend.domain.kupick.KupickErrorCode +import onku.backend.domain.kupick.dto.KupickMemberInfo +import onku.backend.domain.kupick.repository.KupickRepository +import onku.backend.domain.kupick.repository.projection.KupickUrls +import onku.backend.domain.kupick.repository.projection.KupickWithProfile +import onku.backend.domain.member.Member +import onku.backend.global.exception.CustomException +import onku.backend.global.time.TimeRangeUtil +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class KupickService( + private val kupickRepository: KupickRepository, +) { + @Transactional + fun submitApplication(member: Member, applicationUrl : String) : String? { + val monthObject = TimeRangeUtil.getCurrentMonthRange() + val existing = kupickRepository.findFirstByMemberAndApplicationDateBetween( + member, monthObject.startOfMonth, monthObject.startOfNextMonth + ) + + val now = LocalDateTime.now() + + return if (existing != null) { + if(existing.approval) { + throw CustomException(KupickErrorCode.KUPICK_NOT_UPDATE) + } + val old = existing.applicationImageUrl + existing.updateApplication(applicationUrl, now) + old + } else { + kupickRepository.save(Kupick.createApplication(member, applicationUrl, now)) + null + } + } + + @Transactional + fun submitView(member: Member, viewUrl: String) : String? { + val monthObject = TimeRangeUtil.getCurrentMonthRange() + val now = LocalDateTime.now() + val kupick = kupickRepository.findThisMonthByMember( + member, + monthObject.startOfMonth, + monthObject.startOfNextMonth) ?: throw CustomException(KupickErrorCode.KUPICK_APPLICATION_FIRST) + val oldViewUrl = kupick.viewImageUrl + if(kupick.approval) { + throw CustomException(KupickErrorCode.KUPICK_NOT_UPDATE) + } + kupick.submitView(viewUrl, now) + return oldViewUrl + } + + @Transactional(readOnly = true) + fun viewMyKupick(member: Member) : KupickUrls? { + val monthObject = TimeRangeUtil.getCurrentMonthRange() + return kupickRepository.findUrlsForMemberInMonth( + member, + monthObject.startOfMonth, + monthObject.startOfNextMonth + ) + } + + @Transactional(readOnly = true) + fun findAllAsShowUpdateResponse(year : Int, month : Int): List { + val monthObject = TimeRangeUtil.monthRange(year, month) + return kupickRepository.findAllWithProfile( + monthObject.startOfMonth, + monthObject.startOfNextMonth, + ) + } + + @Transactional + fun decideApproval(kupickId: Long, approval: Boolean) { + val kupick = kupickRepository.findByIdOrNull(kupickId) + ?: throw CustomException(KupickErrorCode.KUPICK_NOT_FOUND) + kupick.updateApproval(approval) + } + + @Transactional(readOnly = true) + fun findFcmInfo(kupickId: Long) : KupickMemberInfo? { + return kupickRepository.findFcmInfoByKupickId(kupickId) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/Member.kt b/src/main/kotlin/onku/backend/domain/member/Member.kt new file mode 100644 index 0000000..2d2f811 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/Member.kt @@ -0,0 +1,61 @@ +package onku.backend.domain.member + +import jakarta.persistence.* +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Role +import onku.backend.domain.member.enums.SocialType +import onku.backend.global.entity.BaseEntity + +@Entity +class Member( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + val id: Long? = null, + + @Column(nullable = false, unique = true, length = 255) + var email: String? = null, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var role: Role = Role.USER, + + @Enumerated(EnumType.STRING) + @Column(name = "social_type", nullable = false, length = 20) + val socialType: SocialType, + + @Column(name = "social_id", nullable = false, length = 100) + val socialId: String, + + @Column(name = "has_info", nullable = false) + var hasInfo: Boolean = false, + + @Enumerated(EnumType.STRING) + @Column(name = "approval", nullable = false, length = 20) + var approval: ApprovalStatus = ApprovalStatus.PENDING, + + @Column(name = "is_tf", nullable = false) + var isTf: Boolean = false, + + @Column(name = "is_staff", nullable = false) + var isStaff: Boolean = false, + + @Column(name = "fcm_token") + var fcmToken: String? = null, + + @OneToOne(mappedBy = "member", fetch = FetchType.LAZY) + var memberProfile: MemberProfile? = null +) : BaseEntity() { + fun approve() { + this.approval = ApprovalStatus.APPROVED + this.role = Role.USER + } + fun reject() { this.approval = ApprovalStatus.REJECTED } + fun onboarded() { this.hasInfo = true } + fun updateEmail(newEmail: String?) { + if (newEmail.isNullOrBlank()) return + if (this.email != newEmail) this.email = newEmail + } + fun updateFcmToken(token: String?) { + if (!token.isNullOrBlank()) this.fcmToken = token + } +} diff --git a/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt b/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt new file mode 100644 index 0000000..5cd50da --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt @@ -0,0 +1,25 @@ +package onku.backend.domain.member + +import jakarta.persistence.* +import onku.backend.global.alarm.enums.AlarmEmojiType +import onku.backend.global.entity.BaseEntity +@Entity +@Table(name = "member_alarm_history") +class MemberAlarmHistory( + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_alarm_history_id") + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + val member: Member, + + @Column(name = "message") + val message: String? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + val type: AlarmEmojiType, +) : BaseEntity() diff --git a/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt b/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt new file mode 100644 index 0000000..10f8072 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt @@ -0,0 +1,15 @@ +package onku.backend.domain.member + +import onku.backend.global.exception.ApiErrorCode +import org.springframework.http.HttpStatus + +enum class MemberErrorCode( + override val errorCode: String, + override val message: String, + override val status: HttpStatus +) : ApiErrorCode { + MEMBER_NOT_FOUND("MEMBER404", "회원 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + INVALID_MEMBER_STATE("MEMBER409", "현재 상태에서는 요청한 상태 변경을 수행할 수 없습니다.", HttpStatus.CONFLICT), + PAGE_MEMBERS_NOT_FOUND("MEMBER404_PAGE", "조회할 수 있는 회원이 없습니다.", HttpStatus.NOT_FOUND), + INVALID_REQUEST("MEMBER400", "요청 값이 올바르지 않습니다.", HttpStatus.BAD_REQUEST), +} diff --git a/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt b/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt new file mode 100644 index 0000000..d40cf57 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt @@ -0,0 +1,57 @@ +package onku.backend.domain.member + +import jakarta.persistence.* +import onku.backend.domain.member.enums.Part +import onku.backend.global.crypto.converter.EncryptedStringConverter + +@Entity +class MemberProfile( + @Id + @Column(name = "member_id") + var memberId: Long? = null, + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "member_id") + val member: Member, + + @Column(length = 512) + @Convert(converter = EncryptedStringConverter::class) + var name: String? = null, + + @Column(length = 100) + var school: String? = null, + + @Column(length = 100) + var major: String? = null, + + @Column(length = 50) + @Enumerated(EnumType.STRING) + var part: Part, + + @Column(name = "phone_number", length = 512) + @Convert(converter = EncryptedStringConverter::class) + var phoneNumber: String? = null, + + @Column(name = "profile_image", length = 2048) + @Convert(converter = EncryptedStringConverter::class) + var profileImage: String? = null +) { + fun apply( + name: String, + school: String?, + major: String?, + part: Part, + phoneNumber: String? + ) { + this.name = name + this.school = school + this.major = major + this.part = part + this.phoneNumber = phoneNumber + } + + fun updateProfileImage(url: String?) { + this.profileImage = url + } +} diff --git a/src/main/kotlin/onku/backend/domain/member/controller/manager/MemberExecutiveController.kt b/src/main/kotlin/onku/backend/domain/member/controller/manager/MemberExecutiveController.kt new file mode 100644 index 0000000..13c98e1 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/controller/manager/MemberExecutiveController.kt @@ -0,0 +1,47 @@ +package onku.backend.domain.member.controller.manager + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import onku.backend.domain.member.dto.BulkRoleUpdateRequest +import onku.backend.domain.member.dto.MemberRoleResponse +import onku.backend.domain.member.dto.StaffUpdateRequest +import onku.backend.domain.member.dto.StaffUpdateResponse +import onku.backend.domain.member.service.MemberService +import onku.backend.global.response.SuccessResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/members/executive") +@Tag(name = "[EXECUTIVE] 회원 API", description = "회장단용 운영진 관리 API") +class MemberExecutiveController( + private val memberService: MemberService +) { + + @PatchMapping("/staff") + @Operation( + summary = "운영진 여부 일괄 수정", + description = "체크박스에 체크된 멤버 ID 리스트를 받아 운영진 여부를 일괄 수정합니다." + + "- isStaff = true → false : role.USER 로 변경" + + "- isStaff = false → true : role.STAFF 로 변경" + ) + fun updateStaffMembers( + @RequestBody @Valid req: StaffUpdateRequest + ): ResponseEntity> { + val body = memberService.updateStaffMembers(req) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @PatchMapping("/roles") + @Operation( + summary = "운영진 권한 일괄 수정", + description = "여러 회원의 memberId, role 목록을 받아 한 번에 권한을 수정합니다. GUEST/USER 로의 변경은 허용하지 않습니다." + ) + fun updateRoles( + @RequestBody @Valid req: BulkRoleUpdateRequest + ): ResponseEntity>> { + val body = memberService.updateRoles(req) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } +} diff --git a/src/main/kotlin/onku/backend/domain/member/controller/manager/MemberStaffController.kt b/src/main/kotlin/onku/backend/domain/member/controller/manager/MemberStaffController.kt new file mode 100644 index 0000000..27ed262 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/controller/manager/MemberStaffController.kt @@ -0,0 +1,77 @@ +package onku.backend.domain.member.controller.manager + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import onku.backend.domain.member.dto.* +import onku.backend.domain.member.service.MemberProfileService +import onku.backend.domain.member.service.MemberService +import onku.backend.global.response.SuccessResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/members/staff") +@Tag(name = "[STAFF] 회원 API", description = "운영진용 회원 관리 API") +class MemberStaffController( + private val memberProfileService: MemberProfileService, + private val memberService: MemberService +) { + + @Operation( + summary = "회원 승인 상태 일괄 변경", + description = "PENDING 상태의 회원만 승인/거절할 수 있습니다. (PENDING → APPROVED/REJECTED)" + ) + @PatchMapping("/approvals") + fun updateApproval( + @RequestBody @Valid body: List + ): ResponseEntity>> { + val result = memberService.updateApprovals(body) + return ResponseEntity.ok(SuccessResponse.ok(result)) + } + + @PatchMapping("/{memberId}/profile") + @Operation( + summary = "학회원 프로필 정보 수정", + description = "관리자가 특정 학회원의 [이름, 학교, 학과, 전화번호, 파트]를 수정합니다." + ) + fun updateMemberProfile( + @PathVariable memberId: Long, + @RequestBody @Valid req: MemberProfileUpdateRequest + ): SuccessResponse { + val body = memberProfileService.updateProfile(memberId, req) + return SuccessResponse.ok(body) + } + + @GetMapping("/requests") + @Operation( + summary = "승인 요청 목록 조회 (PENDING/REJECTED)", + description = "PENDING/APPROVED/REJECTED 수와 함께, PENDING 및 REJECTED 상태인 학회원들만 페이징하여 반환합니다." + ) + fun getApprovalRequests( + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int, + ): SuccessResponse { + val safePage = if (page < 1) 0 else page - 1 + val body = memberProfileService.getApprovalRequestMembers(safePage, size) + return SuccessResponse.ok(body) + } + + + @GetMapping("/approved") + @Operation( + summary = "승인된 회원 명단 페이징 조회", + description = "APPROVED 상태인 회원의 id, 이름, 파트, 학교, 학과, 전화번호, 소셜정보, 이메일, role, isStaff 를 페이징하여 반환합니다." + + "isStaff 파라미터로 운영진/비운영진 필터링이 가능하며," + + "상단에는 PENDING/APPROVED/REJECTED 회원 수가 각각 제공됩니다." + ) + fun getApprovedMembersPaged( + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int, + @RequestParam(required = false) isStaff: Boolean? + ): SuccessResponse { + val safePage = if (page < 1) 0 else page - 1 + val body = memberProfileService.getApprovedMembersPagedWithCounts(safePage, size, isStaff) + return SuccessResponse.ok(body) + } +} diff --git a/src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt b/src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt new file mode 100644 index 0000000..dc5b4bf --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt @@ -0,0 +1,77 @@ +package onku.backend.domain.member.controller.user + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import onku.backend.domain.member.Member +import onku.backend.domain.member.dto.* +import onku.backend.domain.member.service.MemberAlarmHistoryService +import onku.backend.domain.member.service.MemberProfileService +import onku.backend.domain.member.service.MemberService +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.response.SuccessResponse +import onku.backend.global.s3.dto.GetUpdateAndDeleteUrlDto +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/members") +@Tag(name = "[USER] 회원 API", description = "온보딩 및 프로필 관련 API") +class MemberUserController( + private val memberProfileService: MemberProfileService, + private val memberService: MemberService, + private val memberAlarmHistoryService: MemberAlarmHistoryService, +) { + @PostMapping("/onboarding") + @ResponseStatus(HttpStatus.ACCEPTED) + @Operation( + summary = "온보딩 정보 제출", + description = "온보딩 전용 토큰으로 접근" + ) + fun submitOnboarding( + @CurrentMember member: Member, + @RequestBody @Valid req: OnboardingRequest + ): SuccessResponse { + val body = memberProfileService.submitOnboarding(member, req) + return SuccessResponse.ok(body) + } + + @GetMapping("/profile/summary") + @Operation( + summary = "내 프로필 요약 조회", + description = "현재 로그인한 회원의 이름(name), 파트(part), 상벌점 총합(totalPoints), 프로필 이미지(profileImage), 이메일(email) 반환" + ) + fun getMyProfileSummary( + @CurrentMember member: Member + ): SuccessResponse { + val body = memberProfileService.getProfileSummary(member) + return SuccessResponse.ok(body) + } + + @GetMapping("/profile/image/url") + @Operation( + summary = "프로필 이미지 업로드용 Presigned URL 발급", + description = "URL 발급과 동시에 DB에 프로필 이미지 key를 저장 or 갱신하며, 이전 이미지 삭제용 URL도 함께 반환" + ) + fun profileImagePostUrl( + @CurrentMember member: Member, + @RequestParam fileName: String + ): ResponseEntity> { + + val dto = memberProfileService.issueProfileImageUploadUrl(member, fileName) + return ResponseEntity.ok(SuccessResponse.ok(dto)) + } + + @GetMapping("/alarms") + @Operation( + summary = "내 알림 히스토리 조회", + description = "현재 로그인한 회원의 알림 메시지 이력을 message, type, createdAt(MM/dd HH:mm) 형식으로 반환합니다." + ) + fun getMyAlarms( + @CurrentMember member: Member + ): SuccessResponse> { + val body = memberAlarmHistoryService.getMyAlarms(member) + return SuccessResponse.ok(body) + } +} diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt new file mode 100644 index 0000000..68d17ff --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt @@ -0,0 +1,17 @@ +package onku.backend.domain.member.dto + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.global.alarm.enums.AlarmEmojiType + +@Schema(description = "내 알림 히스토리 한 건 응답") +data class MemberAlarmHistoryItemResponse( + + @Schema(description = "알림 메시지 내용") + val message: String?, + + @Schema(description = "알림 이모지 타입") + val type: AlarmEmojiType, + + @Schema(description = "알림 생성 시각, 포맷: MM/dd HH:mm", example = "11/19 13:45") + val createdAt: String +) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberApprovalResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberApprovalResponse.kt new file mode 100644 index 0000000..38e3352 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberApprovalResponse.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.member.dto + +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Role + +data class MemberApprovalResponse( + val memberId: Long, + val role: Role, + val approval: ApprovalStatus +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt new file mode 100644 index 0000000..d6a4f51 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt @@ -0,0 +1,122 @@ +package onku.backend.domain.member.dto + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.NotNull +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Part +import onku.backend.domain.member.enums.Role +import onku.backend.domain.member.enums.SocialType +import onku.backend.global.page.PageResponse + +@Schema(description = "회원 한 명에 대한 기본/승인 정보") +data class MemberItemResponse( + + @Schema(description = "회원 ID", example = "1") + val memberId: Long, + + @Schema(description = "이름", example = "김온쿠") + val name: String?, + + @Schema(description = "프로필 이미지 URL", example = "https://cdn.onku.kr/profile/1.png") + val profileImageUrl: String?, + + @Schema(description = "파트", example = "SERVER") + val part: Part, + + @Schema(description = "학교명", example = "한국대학교") + val school: String?, + + @Schema(description = "전공", example = "컴퓨터공학과") + val major: String?, + + @Schema(description = "전화번호", example = "010-1234-5678") + val phoneNumber: String?, + + @Schema(description = "소셜 로그인 타입", example = "GOOGLE") + val socialType: SocialType, + + @Schema(description = "이메일", example = "onku@example.com") + val email: String?, + + @Schema(description = "권한", example = "USER") + val role: Role, + + @Schema(description = "운영진 여부", example = "true") + val isStaff: Boolean, + + @Schema(description = "승인 상태", example = "PENDING") + val approval: ApprovalStatus, +) + +@Schema(description = "회원 페이징 + 상태 별 회원 수 응답") +data class MembersPagedResponse( + + @Schema(description = "승인 대기 중인 회원 수", example = "5") + val pendingCount: Long, + + @Schema(description = "승인 완료된 회원 수", example = "40") + val approvedCount: Long, + + @Schema(description = "승인 거절된 회원 수", example = "3") + val rejectedCount: Long, + + @Schema(description = "APPROVED 상태 회원 페이징 결과") + val members: PageResponse, +) + +@Schema(description = "운영진(isStaff) 일괄 수정 요청") +data class StaffUpdateRequest( + + @field:NotNull + @Schema( + description = "운영진으로 설정할 회원 ID 리스트 (체크박스 선택 결과)", + example = "[1, 2, 3, 4]" + ) + val staffMemberIds: List = emptyList() +) + +@Schema(description = "운영진(isStaff) 일괄 수정 결과 응답") +data class StaffUpdateResponse( + + @Schema( + description = "이번 요청으로 새롭게 운영진이 된 회원 ID 리스트", + example = "[12, 24, 31, 55]" + ) + val addedStaffs: List, + + @Schema( + description = "이번 요청으로 운영진에서 해제된 회원 ID 리스트", + example = "[7, 19]" + ) + val removedStaffs: List +) + +@Schema(description = "운영진 권한(ROLE) 한 건 변경 요청") +data class BulkRoleUpdateItem( + + @field:NotNull + @Schema(description = "권한을 변경할 회원 ID", example = "1") + val memberId: Long?, + + @field:NotNull + @Schema( + description = "변경할 권한 (GUEST/USER 로의 변경은 허용하지 않음)", + example = "STAFF" + ) + val role: Role? +) + +@Schema(description = "운영진 권한(ROLE) 일괄 변경 요청") +data class BulkRoleUpdateRequest( + + @field:NotEmpty + @Schema( + description = "여러 회원의 권한 변경 요청 리스트", + example = """[ + {"memberId": 1, "role": "STAFF"}, + {"memberId": 2, "role": "MANAGEMENT"} + ]""" + ) + val items: List +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileBasicsResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileBasicsResponse.kt new file mode 100644 index 0000000..c39715f --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileBasicsResponse.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.member.dto + +import onku.backend.domain.member.enums.Part + +data class MemberProfileBasicsResponse( + val name: String, + val part: Part, + val school: String?, + val profileImageUrl: String? +) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt new file mode 100644 index 0000000..5a941d2 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt @@ -0,0 +1,29 @@ +package onku.backend.domain.member.dto + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.member.enums.Part + +@Schema(description = "내 프로필 요약 응답") +data class MemberProfileResponse( + + @field:Schema( + description = "이메일", + example = "onku@example.com" + ) + val email: String?, + + @field:Schema( + description = "이름", + example = "김온쿠" + ) + val name: String?, + + @field:Schema(description = "파트", example = "SERVER") + val part: Part, + + @field:Schema(description = "상벌점 총합", example = "15") + val totalPoints: Long, + + @field:Schema(description = "프로필 이미지 URL", example = "https://.../profile.png") + val profileImage: String? +) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileUpdateRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileUpdateRequest.kt new file mode 100644 index 0000000..fccb283 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileUpdateRequest.kt @@ -0,0 +1,23 @@ +package onku.backend.domain.member.dto + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.member.enums.Part + +@Schema(description = "프로필 기본 정보 수정 요청") +data class MemberProfileUpdateRequest( + + @Schema(description = "이름", example = "김온쿠") + val name: String, + + @Schema(description = "학교명", example = "한국대학교") + val school: String? = null, + + @Schema(description = "전공", example = "컴퓨터공학과") + val major: String? = null, + + @Schema(description = "파트", example = "SERVER") + val part: Part, + + @Schema(description = "전화번호", example = "010-1234-5678") + val phoneNumber: String? = null, +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberRoleResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberRoleResponse.kt new file mode 100644 index 0000000..535ddc3 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberRoleResponse.kt @@ -0,0 +1,11 @@ +package onku.backend.domain.member.dto + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.member.enums.Role + +data class MemberRoleResponse( + @Schema(description = "사용자 ID", example = "1") + val memberId: Long, + @Schema(description = "변경된 권한", example = "STAFF") + val role: Role +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt new file mode 100644 index 0000000..3c1209e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt @@ -0,0 +1,16 @@ +package onku.backend.domain.member.dto + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import onku.backend.domain.member.enums.Part + +data class OnboardingRequest ( + @field:NotBlank val name: String, + @field:NotBlank val school: String, + @field:NotBlank val major: String, + val part: Part, + val phoneNumber: String? = null, + + @Schema(description = "FCM 토큰", example = "eYJhbGciOi...") + val fcmToken: String? = null, +) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/OnboardingResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingResponse.kt new file mode 100644 index 0000000..27decad --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingResponse.kt @@ -0,0 +1,7 @@ +package onku.backend.domain.member.dto + +import onku.backend.domain.member.enums.ApprovalStatus + +data class OnboardingResponse( + val status: ApprovalStatus, +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/dto/UpdateApprovalRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/UpdateApprovalRequest.kt new file mode 100644 index 0000000..fbab282 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/UpdateApprovalRequest.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.member.dto + +import jakarta.validation.constraints.NotNull +import onku.backend.domain.member.enums.ApprovalStatus + +data class UpdateApprovalRequest( + @field:NotNull + val memberId: Long?, + + @field:NotNull + val status: ApprovalStatus? +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/dto/UpdateProfileImageRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/UpdateProfileImageRequest.kt new file mode 100644 index 0000000..c8db52e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/UpdateProfileImageRequest.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.member.dto + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class UpdateProfileImageRequest( + @field:NotBlank + @field:Size(max = 2048) + @Schema(description = "새 프로필 이미지 URL", example = "https://s3.../member_profile/1/uuid/profile.png") + val imageUrl: String +) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/UpdateProfileImageResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/UpdateProfileImageResponse.kt new file mode 100644 index 0000000..042103a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/UpdateProfileImageResponse.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.member.dto + +import io.swagger.v3.oas.annotations.media.Schema + +data class UpdateProfileImageResponse( + @Schema(description = "수정된 프로필 이미지 URL") + val profileImageUrl: String +) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/UpdateRoleRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/UpdateRoleRequest.kt new file mode 100644 index 0000000..375dd07 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/UpdateRoleRequest.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.member.dto + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotNull +import onku.backend.domain.member.enums.Role + +data class UpdateRoleRequest( + + @field:NotNull + @Schema(description = "변경할 권한", example = "STAFF") + val role: Role? +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt b/src/main/kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt new file mode 100644 index 0000000..5181fa1 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt @@ -0,0 +1,13 @@ +package onku.backend.domain.member.enums + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = "회원가입 승인 상태", + example = "APPROVED" +) +enum class ApprovalStatus { + @Schema(description = "승인 대기") PENDING, + @Schema(description = "승인 완료") APPROVED, + @Schema(description = "반려") REJECTED +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/enums/Part.kt b/src/main/kotlin/onku/backend/domain/member/enums/Part.kt new file mode 100644 index 0000000..2f61d33 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/enums/Part.kt @@ -0,0 +1,14 @@ +package onku.backend.domain.member.enums + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = "학회원 정보 중 파트 구분", + example = "BACKEND" +) +enum class Part { + @Schema(description = "백엔드") BACKEND, + @Schema(description = "프론트엔드") FRONTEND, + @Schema(description = "디자인") DESIGN, + @Schema(description = "기획") PLANNING +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/enums/Role.kt b/src/main/kotlin/onku/backend/domain/member/enums/Role.kt new file mode 100644 index 0000000..7888042 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/enums/Role.kt @@ -0,0 +1,23 @@ +package onku.backend.domain.member.enums + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = "사용자 권한 계층: EXECUTIVE(회장단) > MANAGEMENT(경총) > STAFF(운영진) > USER(학회원) > GUEST(온보딩)", + example = "USER" +) +enum class Role { + @Schema(description = "게스트 (온보딩 전용)") GUEST, + @Schema(description = "학회원 (일반 사용자)") USER, + @Schema(description = "운영진 (사용자 권한 포함)") STAFF, + @Schema(description = "경총 (운영진+사용자 권한 포함)") MANAGEMENT, + @Schema(description = "회장단 (경총+운영진+사용자 권한 포함)") EXECUTIVE; + + fun authorities(): List = when (this) { + GUEST -> listOf("GUEST") + USER -> listOf("USER", "GUEST") + STAFF -> listOf("STAFF", "USER", "GUEST") + MANAGEMENT -> listOf("MANAGEMENT", "STAFF", "USER", "GUEST") + EXECUTIVE -> listOf("EXECUTIVE", "MANAGEMENT", "STAFF", "USER", "GUEST") + } +} diff --git a/src/main/kotlin/onku/backend/domain/member/enums/SocialType.kt b/src/main/kotlin/onku/backend/domain/member/enums/SocialType.kt new file mode 100644 index 0000000..2085f11 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/enums/SocialType.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.member.enums + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = "소셜 로그인 종류", + example = "KAKAO" +) +enum class SocialType { + @Schema(description = "카카오") KAKAO, + @Schema(description = "애플") APPLE +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberAlarmHistoryRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberAlarmHistoryRepository.kt new file mode 100644 index 0000000..17452c5 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberAlarmHistoryRepository.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.member.repository + +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberAlarmHistory +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberAlarmHistoryRepository : JpaRepository { + + fun findByMemberOrderByCreatedAtDesc(member: Member): List +} diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt new file mode 100644 index 0000000..7db8899 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt @@ -0,0 +1,44 @@ +package onku.backend.domain.member.repository + +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.enums.ApprovalStatus +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.EntityGraph + +interface MemberProfileRepository : JpaRepository { + fun existsByMember_Id(memberId: Long): Boolean + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from MemberProfile mp where mp.member.id = :memberId") + fun deleteByMemberId(@Param("memberId") memberId: Long): Int + + @EntityGraph(attributePaths = ["member"]) + fun findAllByOrderByPartAscNameAsc(pageable: Pageable): Page + + // PENDING, REJECTED + @EntityGraph(attributePaths = ["member"]) + fun findByMemberApprovalIn( + approvals: Collection, + pageable: Pageable + ): Page + + // APPROVED + @EntityGraph(attributePaths = ["member"]) + fun findByMemberApproval( + approval: ApprovalStatus, + pageable: Pageable + ): Page + + // STAFF + @EntityGraph(attributePaths = ["member"]) + fun findByMemberApprovalAndMemberIsStaff( + approval: ApprovalStatus, + isStaff: Boolean, + pageable: Pageable + ): Page +} diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt new file mode 100644 index 0000000..bf520f1 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt @@ -0,0 +1,21 @@ +package onku.backend.domain.member.repository + +import onku.backend.domain.member.Member +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.SocialType +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface MemberRepository : JpaRepository { + fun findByEmail(email: String): Member? + fun findBySocialIdAndSocialType(socialId: String, socialType: SocialType): Member? + @Query(""" + select m.id from Member m + where m.hasInfo = true + and m.approval = onku.backend.domain.member.enums.ApprovalStatus.APPROVED + """) + fun findApprovedMemberIds(): List + fun countByApproval(approval: ApprovalStatus): Long + fun findByIsStaffTrue(): List + fun findByIdIn(ids: Collection): List +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberAlarmHistoryService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberAlarmHistoryService.kt new file mode 100644 index 0000000..b8b54cd --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberAlarmHistoryService.kt @@ -0,0 +1,42 @@ +package onku.backend.domain.member.service + +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberAlarmHistory +import onku.backend.domain.member.dto.MemberAlarmHistoryItemResponse +import onku.backend.domain.member.repository.MemberAlarmHistoryRepository +import onku.backend.global.alarm.enums.AlarmEmojiType +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.format.DateTimeFormatter + +@Service +@Transactional(readOnly = true) +class MemberAlarmHistoryService( + private val memberAlarmHistoryRepository: MemberAlarmHistoryRepository +) { + + private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/dd HH:mm") + + fun getMyAlarms(member: Member): List { + val histories = memberAlarmHistoryRepository.findByMemberOrderByCreatedAtDesc(member) + + return histories.map { + MemberAlarmHistoryItemResponse( + message = it.message, + type = it.type, + createdAt = it.createdAt.format(formatter) + ) + } + } + + @Transactional + fun saveAlarm(member: Member, alarmEmojiType: AlarmEmojiType, message : String) { + memberAlarmHistoryRepository.save( + MemberAlarmHistory( + member = member, + message = message, + type = alarmEmojiType + ) + ) + } +} diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt new file mode 100644 index 0000000..4d3514d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -0,0 +1,276 @@ +package onku.backend.domain.member.service + +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.dto.* +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.page.PageResponse +import onku.backend.global.s3.dto.GetUpdateAndDeleteUrlDto +import onku.backend.global.s3.enums.FolderName +import onku.backend.global.s3.enums.UploadOption +import onku.backend.global.s3.service.S3Service +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional +class MemberProfileService( + private val memberProfileRepository: MemberProfileRepository, + private val memberRepository: MemberRepository, + private val memberService: MemberService, + private val memberPointHistoryRepository: MemberPointHistoryRepository, + private val s3Service: S3Service +) { + fun submitOnboarding(member: Member, req: OnboardingRequest): OnboardingResponse { + if (member.hasInfo) { // 이미 온보딩 완료된 사용자 차단 + throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) + } + if (member.approval != ApprovalStatus.PENDING) { // PENDING 상태가 아닌 사용자 차단 + throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) + } + + // FCM 토큰 저장 + val m = memberRepository.findById(member.id!!) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + m.updateFcmToken(req.fcmToken) + + createOrUpdateProfile(m.id!!, req) + memberService.markOnboarded(m) + + return OnboardingResponse( + status = ApprovalStatus.PENDING + ) + } + + private fun createOrUpdateProfile(memberId: Long, req: OnboardingRequest) { + val member = memberRepository.findById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + val existing = memberProfileRepository.findById(memberId).orElse(null) + if (existing == null) { + val profile = MemberProfile( + member = member, + name = req.name, + school = req.school, + major = req.major, + part = req.part, + phoneNumber = req.phoneNumber, + profileImage = null + ) + memberProfileRepository.save(profile) + } else { + existing.apply( + name = req.name, + school = req.school, + major = req.major, + part = req.part, + phoneNumber = req.phoneNumber + ) + } + } + + @Transactional(readOnly = true) + fun getProfileSummary(member: Member): MemberProfileResponse { + val profile = memberProfileRepository.findById(member.id!!) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + val sums = memberPointHistoryRepository.sumPointsForMember(member) + val total = sums.getTotalPoints() + + val key = profile.profileImage + val url = key?.let { s3Service.getGetS3Url(member.id!!, it).preSignedUrl } + + return MemberProfileResponse( + email = member.email, + name = profile.name, + part = profile.part, + totalPoints = total, + profileImage = url + ) + } + + @Transactional(readOnly = true) + fun getProfileBasics(member: Member): MemberProfileBasicsResponse { + val profile = memberProfileRepository.findById(member.id!!) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + val key = profile.profileImage + val url = key?.let { s3Service.getGetS3Url(member.id!!, it).preSignedUrl } + + return MemberProfileBasicsResponse( + name = profile.name ?: "Unknown", + part = profile.part, + school = profile.school, + profileImageUrl = url + ) + } + + @Transactional + fun issueProfileImageUploadUrl(member: Member, fileName: String): GetUpdateAndDeleteUrlDto { + val signed = s3Service.getPostS3Url( + memberId = member.id!!, + filename = fileName, + folderName = FolderName.MEMBER_PROFILE.name, + option = UploadOption.IMAGE + ) + + val oldKey = submitProfileImage(member, signed.key) + + val oldDeletePreSignedUrl = if (!oldKey.isNullOrBlank()) { + s3Service.getDeleteS3Url(oldKey).preSignedUrl + } else { + "" + } + + return GetUpdateAndDeleteUrlDto( + newUrl = signed.preSignedUrl, + oldUrl = oldDeletePreSignedUrl + ) + } + + fun submitProfileImage(member: Member, newKey: String): String? { + val profile = memberProfileRepository.findById(member.id!!) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + val old = profile.profileImage + profile.updateProfileImage(newKey) + return old + } + + fun updateProfile( + memberId: Long, + req: MemberProfileUpdateRequest + ): MemberProfileBasicsResponse { + memberRepository.findById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + val profile = memberProfileRepository.findById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + profile.apply( + name = req.name, + school = req.school, + major = req.major, + part = req.part, + phoneNumber = req.phoneNumber + ) + + val key = profile.profileImage + val url = key?.let { s3Service.getGetS3Url(memberId, it).preSignedUrl } + + return MemberProfileBasicsResponse( + name = profile.name ?: "Unknown", + part = profile.part, + school = profile.school, + profileImageUrl = url + ) + } + + @Transactional(readOnly = true) + fun getApprovedMembersPagedWithCounts( + page: Int, + size: Int, + isStaff: Boolean? + ): MembersPagedResponse { + + val pendingCount = memberRepository.countByApproval(ApprovalStatus.PENDING) + val approvedCount = memberRepository.countByApproval(ApprovalStatus.APPROVED) + val rejectedCount = memberRepository.countByApproval(ApprovalStatus.REJECTED) + + val pageable = PageRequest.of( + page, + size, + Sort.by( + Sort.Order.asc("part"), + Sort.Order.asc("name") + ) + ) + + val profilePage = when (isStaff) { + null -> memberProfileRepository.findByMemberApproval( + ApprovalStatus.APPROVED, + pageable + ) + else -> memberProfileRepository.findByMemberApprovalAndMemberIsStaff( + ApprovalStatus.APPROVED, + isStaff, + pageable + ) + } + val dtoPage = profilePage.map { profile -> + val member = profile.member + val key = profile.profileImage + val url = key?.let { s3Service.getGetS3Url(member.id!!, it).preSignedUrl } + + MemberItemResponse( + memberId = member.id!!, + name = profile.name, + profileImageUrl = url, + part = profile.part, + school = profile.school, + major = profile.major, + phoneNumber = profile.phoneNumber, + socialType = member.socialType, + email = member.email, + role = member.role, + isStaff = member.isStaff, + approval = member.approval + ) + } + val pageResponse = PageResponse.from(dtoPage) + return MembersPagedResponse( + pendingCount = pendingCount, + approvedCount = approvedCount, + rejectedCount = rejectedCount, + members = pageResponse + ) + } + + @Transactional(readOnly = true) + fun getApprovalRequestMembers(page: Int, size: Int): MembersPagedResponse { + val pendingCount = memberRepository.countByApproval(ApprovalStatus.PENDING) + val approvedCount = memberRepository.countByApproval(ApprovalStatus.APPROVED) + val rejectedCount = memberRepository.countByApproval(ApprovalStatus.REJECTED) + + val pageable = PageRequest.of(page, size) + + val approvalStatuses = listOf(ApprovalStatus.PENDING, ApprovalStatus.REJECTED) + val profilePage = memberProfileRepository.findByMemberApprovalIn(approvalStatuses, pageable) + + val memberPage = profilePage.map { profile -> + val member = profile.member + val key = profile.profileImage + val url = key?.let { s3Service.getGetS3Url(member.id!!, it).preSignedUrl } + + MemberItemResponse( + memberId = member.id!!, + name = profile.name, + profileImageUrl = url, + part = profile.part, + school = profile.school, + major = profile.major, + phoneNumber = profile.phoneNumber, + socialType = member.socialType, + email = member.email, + role = member.role, + isStaff = member.isStaff, + approval = member.approval + ) + } + val membersPageResponse: PageResponse = PageResponse.from(memberPage) + + return MembersPagedResponse( + pendingCount = pendingCount, + approvedCount = approvedCount, + rejectedCount = rejectedCount, + members = membersPageResponse + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt new file mode 100644 index 0000000..5cb9dd6 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -0,0 +1,211 @@ +package onku.backend.domain.member.service + +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.dto.* +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Role +import onku.backend.domain.member.enums.SocialType +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.global.auth.AuthErrorCode +import onku.backend.global.exception.CustomException +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class MemberService( + private val memberRepository: MemberRepository, + private val memberProfileRepository: MemberProfileRepository, +) { + fun getByEmail(email: String): Member = + memberRepository.findByEmail(email) + ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + + @Transactional + fun upsertSocialMember(email: String?, socialId: String, type: SocialType): Member { + // social로 먼저 조회: Apple 재로그인 시 email 누락 때문 + val bySocial = memberRepository.findBySocialIdAndSocialType(socialId, type) + if (bySocial != null) { + bySocial.updateEmail(email) + return bySocial + } + + // email이 있으면 email로 조회: email 중복 삽입 방지 + val byEmail = email?.let { memberRepository.findByEmail(it) } + if (byEmail != null) { + return byEmail + } + + // 신규 생성: email 없으면 생성 불가 처리 + val safeEmail = email ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) + val newMember = Member( + email = safeEmail, + socialType = type, + socialId = socialId + ) + return memberRepository.save(newMember) + } + + @Transactional + fun markOnboarded(member: Member) { + val m = memberRepository.findById(member.id!!) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + if (!m.hasInfo) { + m.onboarded() + memberRepository.save(m) + } + } + + @Transactional + fun deleteMemberById(memberId: Long) { + if (!memberRepository.existsById(memberId)) { + throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + } + if (memberProfileRepository.existsByMember_Id(memberId)) { + memberProfileRepository.deleteByMemberId(memberId) + } + memberRepository.deleteById(memberId) + } + + @Transactional + fun updateApprovals(items: List): List { + if (items.isEmpty()) return emptyList() + + val ids = items.mapNotNull { it.memberId }.toSet() + val members = memberRepository.findByIdIn(ids) + if (members.size != ids.size) { + throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + } + + val memberMap = members.associateBy { it.id!! } + val responses = mutableListOf() + + items.forEach { item -> + val memberId = item.memberId ?: throw CustomException(MemberErrorCode.INVALID_REQUEST) + val targetStatus = item.status ?: throw CustomException(MemberErrorCode.INVALID_REQUEST) + + if (targetStatus == ApprovalStatus.PENDING) { + throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) + } + + val member = memberMap[memberId] + ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + + if (member.approval != ApprovalStatus.PENDING) { + throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) + } + when (targetStatus) { + ApprovalStatus.APPROVED -> member.approve() + ApprovalStatus.REJECTED -> member.reject() + ApprovalStatus.PENDING -> { } + } + responses.add( + MemberApprovalResponse( + memberId = memberId, + role = member.role, + approval = member.approval + ) + ) + } + memberRepository.saveAll(memberMap.values) + return responses + } + + @Transactional + fun updateRole( + memberId: Long, + req: UpdateRoleRequest + ): MemberRoleResponse { + val newRole = req.role ?: throw CustomException(MemberErrorCode.INVALID_REQUEST) + + val target = memberRepository.findByIdOrNull(memberId) + ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + + target.role = newRole + memberRepository.save(target) + + return MemberRoleResponse( + memberId = target.id!!, + role = target.role + ) + } + + @Transactional + fun updateStaffMembers(req: StaffUpdateRequest): StaffUpdateResponse { + val targetIds = req.staffMemberIds.toSet() + + // 현재 운영진 + val currentStaffMembers = memberRepository.findByIsStaffTrue() + val currentStaffIds = currentStaffMembers.mapNotNull { it.id }.toSet() + + val addedStaffIds = (targetIds - currentStaffIds) + val removedStaffIds = (currentStaffIds - targetIds) + + // 운영진 추가 + if (addedStaffIds.isNotEmpty()) { + val toAdd = memberRepository.findByIdIn(addedStaffIds) + if (toAdd.size != addedStaffIds.size) { + throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + } + toAdd.forEach { m -> + m.isStaff = true + m.role = Role.STAFF // isStaff = false → true : role.STAFF + } + } + + // 운영진 삭제 + if (removedStaffIds.isNotEmpty()) { + val toRemove = currentStaffMembers.filter { it.id in removedStaffIds } + toRemove.forEach { m -> + m.isStaff = false + m.role = Role.USER // isStaff = true → false : role.USER + } + } + + return StaffUpdateResponse( + addedStaffs = addedStaffIds.sorted(), + removedStaffs = removedStaffIds.sorted() + ) + } + + @Transactional + fun updateRoles(req: BulkRoleUpdateRequest): List { + if (req.items.isEmpty()) return emptyList() + + val ids = req.items.mapNotNull { it.memberId }.toSet() + val members = memberRepository.findByIdIn(ids) + if (members.size != ids.size) { + throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + } + + val memberMap = members.associateBy { it.id!! } + val responses = mutableListOf() + + req.items.forEach { item -> + val memberId = item.memberId ?: throw CustomException(MemberErrorCode.INVALID_REQUEST) + val newRole = item.role ?: throw CustomException(MemberErrorCode.INVALID_REQUEST) + + if (newRole == Role.USER || newRole == Role.GUEST) { + throw CustomException(MemberErrorCode.INVALID_REQUEST) + } + + val member = memberMap[memberId] + ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + + member.role = newRole + + responses.add( + MemberRoleResponse( + memberId = memberId, + role = member.role + ) + ) + } + memberRepository.saveAll(memberMap.values) + return responses + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/Notice.kt b/src/main/kotlin/onku/backend/domain/notice/Notice.kt new file mode 100644 index 0000000..6180ff9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/Notice.kt @@ -0,0 +1,61 @@ +package onku.backend.domain.notice + +import jakarta.persistence.* +import onku.backend.domain.member.Member +import onku.backend.domain.notice.enums.NoticeStatus +import java.time.LocalDateTime + +@Entity +@Table(name = "notice") +class Notice( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notice_id") + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "member_id", nullable = false) + val member: Member, + + @Column(name = "title") + var title: String? = null, + + @Column(name = "content", columnDefinition = "TEXT") + var content: String? = null, + + @Column(name = "published_at") + var publishedAt: LocalDateTime? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "status") + var status: NoticeStatus? = NoticeStatus.PUBLISHED +) { + @ManyToMany + @JoinTable( + name = "notice_category", + joinColumns = [JoinColumn(name = "notice_id")], + inverseJoinColumns = [JoinColumn(name = "category_id")] + ) + var categories: MutableSet = linkedSetOf() + + @OneToMany(mappedBy = "notice", cascade = [CascadeType.ALL], orphanRemoval = true) + var attachments: MutableList = mutableListOf() + + // 단일 첨부파일 하나를 공지에 추가 + fun addFile(file: NoticeAttachment) { + attachments.add(file) + file.notice = this + } + + // 단일 첨부파일 하나만 공지에서 제거 + fun removeFile(file: NoticeAttachment) { + attachments.remove(file) + file.notice = null + } + + // 공지에 연결된 모든 첨부파일을 한 번에 제거 + fun clearFiles() { + val copy = attachments.toList() + copy.forEach { removeFile(it) } + } +} diff --git a/src/main/kotlin/onku/backend/domain/notice/NoticeAttachment.kt b/src/main/kotlin/onku/backend/domain/notice/NoticeAttachment.kt new file mode 100644 index 0000000..c179b00 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/NoticeAttachment.kt @@ -0,0 +1,27 @@ +package onku.backend.domain.notice + +import jakarta.persistence.* +import onku.backend.global.s3.enums.UploadOption + +@Entity +@Table(name = "notice_attachment") +class NoticeAttachment( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notice_attachment_id") + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY, optional = true) + @JoinColumn(name = "notice_id", nullable = true) + var notice: Notice? = null, // 공지 생성 전에도 이미지 업로드를 허용하기 위해 nullable + + @Column(name = "s3_key", nullable = false) + var s3Key: String, + + @Enumerated(EnumType.STRING) + @Column(name = "attachment_type", nullable = false) + val attachmentType: UploadOption, + + @Column(name = "attachment_size") + val attachmentSize: Long? = null, // 파일 크기 +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/NoticeCategory.kt b/src/main/kotlin/onku/backend/domain/notice/NoticeCategory.kt new file mode 100644 index 0000000..ada878c --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/NoticeCategory.kt @@ -0,0 +1,29 @@ +package onku.backend.domain.notice + +import jakarta.persistence.* +import onku.backend.domain.notice.enums.NoticeCategoryColor + +@Entity +@Table( + name = "category", + uniqueConstraints = [ + UniqueConstraint(name = "uq_category_name", columnNames = ["name"]), + UniqueConstraint(name = "uq_category_color", columnNames = ["color"]) + ] +) +class NoticeCategory( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "category_id") + val id: Long? = null, + + @Column(name = "name", nullable = false, length = 50) + var name: String, + + @Enumerated(EnumType.STRING) + @Column(name = "color", nullable = false) + var color: NoticeCategoryColor +) { + @ManyToMany(mappedBy = "categories") + val notices: MutableSet = linkedSetOf() +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/NoticeErrorCode.kt b/src/main/kotlin/onku/backend/domain/notice/NoticeErrorCode.kt new file mode 100644 index 0000000..2ac8204 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/NoticeErrorCode.kt @@ -0,0 +1,18 @@ +package onku.backend.domain.notice + +import onku.backend.global.exception.ApiErrorCode +import org.springframework.http.HttpStatus + +enum class NoticeErrorCode( + override val errorCode: String, + override val message: String, + override val status: HttpStatus +) : ApiErrorCode { + NOTICE_NOT_FOUND("NOT100", "존재하지 않는 공지입니다.", HttpStatus.NOT_FOUND), + + CATEGORY_NOT_FOUND("NOT001", "존재하지 않는 카테고리입니다.", HttpStatus.NOT_FOUND), + CATEGORY_NAME_DUPLICATE("NOT002", "이미 존재하는 카테고리 이름입니다.", HttpStatus.CONFLICT), + CATEGORY_COLOR_DUPLICATE("NOT003", "이미 사용 중인 카테고리 색상입니다.", HttpStatus.CONFLICT), + CATEGORY_NAME_TOO_LONG("NOT004", "카테고리 이름은 7자 이상 등록할 수 없습니다.", HttpStatus.BAD_REQUEST), + CATEGORY_LINKED_NOT_DELETABLE("NOT005", "해당 카테고리로 작성된 공지가 있어 삭제할 수 없습니다.", HttpStatus.CONFLICT), +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeCategoryController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeCategoryController.kt new file mode 100644 index 0000000..ff18958 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeCategoryController.kt @@ -0,0 +1,67 @@ +package onku.backend.domain.notice.controller.manager + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import onku.backend.domain.notice.dto.category.* +import onku.backend.domain.notice.service.NoticeCategoryService +import onku.backend.global.response.SuccessResponse + +@RestController +@RequestMapping("/api/v1/notice/categories") +@Tag( + name = "[STAFF] 공지 카테고리 API", + description = "공지 카테고리 관련 API" +) +class NoticeCategoryController( + private val noticeCategoryService: NoticeCategoryService +) { + + @GetMapping + @Operation(summary = "카테고리 조회 [운영진]", description = "{id, name, color} 목록 반환") + fun list(): ResponseEntity>> { + val body = noticeCategoryService.list() + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @GetMapping("/colors/available") + @Operation(summary = "사용 가능한 카테고리 색상 조회 [운영진]", description = "아직 사용되지 않은 색상 리스트 반환") + fun availableColors(): ResponseEntity> { + val body = noticeCategoryService.availableColors() + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @PostMapping + @Operation( + summary = "카테고리 등록 [운영진]", + description = "같은 이름 불가, 이름 7자 이상 불가, 색상 중복 불가" + ) + fun create( + @RequestBody @Valid req: NoticeCategoryCreateRequest + ): ResponseEntity> { + val body = noticeCategoryService.create(req) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @PutMapping("/{categoryId}") + @Operation(summary = "카테고리 수정 [운영진]", description = "이름/색상 수정. 제약 동일") + fun update( + @PathVariable categoryId: Long, + @RequestBody @Valid req: NoticeCategoryUpdateRequest + ): ResponseEntity> { + val body = noticeCategoryService.update(categoryId, req) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @DeleteMapping("/{categoryId}") + @Operation( + summary = "카테고리 삭제 [운영진]", + description = "해당 카테고리로 작성된 공지가 있으면 삭제 불가" + ) + fun delete(@PathVariable categoryId: Long): ResponseEntity> { + noticeCategoryService.delete(categoryId) + return ResponseEntity.ok(SuccessResponse.ok(Unit)) + } +} diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeManagerController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeManagerController.kt new file mode 100644 index 0000000..d8e11a5 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeManagerController.kt @@ -0,0 +1,81 @@ +package onku.backend.domain.notice.controller.manager + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import onku.backend.domain.member.Member +import onku.backend.domain.notice.dto.notice.NoticeCreateRequest +import onku.backend.domain.notice.dto.notice.NoticeDetailResponse +import onku.backend.domain.notice.dto.notice.NoticeUpdateRequest +import onku.backend.domain.notice.dto.notice.PresignedUploadResponse +import onku.backend.domain.notice.service.NoticeAttachmentService +import onku.backend.domain.notice.service.NoticeService +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.response.SuccessResponse +import onku.backend.global.s3.enums.UploadOption + +@RestController +@RequestMapping("/api/v1/notice/manage") +@Tag( + name = "[STAFF] 공지 관리 API", + description = "공지 생성/수정/삭제/상세 조회 API" +) +class NoticeManagerController( + private val noticeService: NoticeService, + private val noticeAttachmentService: NoticeAttachmentService +) { + + @PostMapping + @Operation( + summary = "공지 등록", + description = "제목, 카테고리, 내용, 이미지, pdf로 등록" + ) + fun create( + @CurrentMember member: Member, + @RequestBody @Valid req: NoticeCreateRequest + ): ResponseEntity> { + val body = noticeService.create(member, req) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @PostMapping("files") + @Operation( + summary = "공지 이미지/파일 업로드 URL 발급 [운영진]", + description = "filename, fileSize 를 받아 presigned PUT url 발급" + ) + fun prepareUpload( + @RequestParam filename: String, + @RequestParam fileType: UploadOption, + @RequestParam fileSize: Long, + @CurrentMember member: Member + ): ResponseEntity> { + val body = noticeAttachmentService.prepareUpload(member, filename, fileType, fileSize) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @PutMapping("/{noticeId}") + @Operation( + summary = "공지 수정", + description = "등록과 동일한 request/response" + ) + fun update( + @PathVariable noticeId: Long, + @CurrentMember member: Member, + @RequestBody @Valid req: NoticeUpdateRequest + ): ResponseEntity> { + val body = noticeService.update(noticeId, member, req) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @DeleteMapping("/{noticeId}") + @Operation( + summary = "공지 삭제", + description = "공지 삭제" + ) + fun delete(@PathVariable noticeId: Long): ResponseEntity> { + noticeService.delete(noticeId) + return ResponseEntity.ok(SuccessResponse.ok(Unit)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/user/NoticeController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/user/NoticeController.kt new file mode 100644 index 0000000..6f2098d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/controller/user/NoticeController.kt @@ -0,0 +1,68 @@ +package onku.backend.domain.notice.controller.user + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import onku.backend.domain.member.Member +import onku.backend.domain.notice.dto.notice.NoticeDetailResponse +import onku.backend.domain.notice.dto.notice.NoticeListItemResponse +import onku.backend.domain.notice.service.NoticeService +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.page.PageResponse +import onku.backend.global.response.SuccessResponse + +@RestController +@RequestMapping("/api/v1/notice") +@Tag( + name = "[USER/MANAGEMENT] 공지 API", + description = "공지 조회/검색 API" +) +class NoticeController( + private val noticeService: NoticeService +) { + + @GetMapping + @Operation( + summary = "공지 리스트 조회", + description = "id, 제목, 작성자id, 작성자이름, {카테고리 이름/색}, 작성일(YYYY/MM/DD HH:MM), 상태, 이미지, 파일을 페이징하여 반환. 전체 조회가 필요한 경우 카테고리 id는 비워두시면 됩니다." + ) + fun list( + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int, + @RequestParam(required = false) categoryId: Long?, + @CurrentMember member: Member + ): ResponseEntity>> { + val safePage = if (page < 1) 0 else page - 1 + val body = noticeService.list(member, safePage, size, categoryId) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @GetMapping("/{noticeId}") + @Operation( + summary = "공지 단일 조회", + description = "제목, 카테고리, 작성일자(MM:dd HH:MM), 내용, 작성자id, 작성자이름, 이미지, 파일 presigned url 리스트" + ) + fun get( + @PathVariable noticeId: Long, + @CurrentMember member: Member + ): ResponseEntity> { + val body = noticeService.get(noticeId, member) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @GetMapping("/search") + @Operation( + summary = "공지 검색", + description = "검색어로 공지 제목/내용에서 검색하여 페이징 반환합니다." + ) + fun search( + @RequestParam keyword: String, + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int, + ): ResponseEntity>> { + val safePage = if (page < 1) 0 else page - 1 + val body = noticeService.search(keyword, safePage, size) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } +} diff --git a/src/main/kotlin/onku/backend/domain/notice/dto/category/AvailableColorsResponse.kt b/src/main/kotlin/onku/backend/domain/notice/dto/category/AvailableColorsResponse.kt new file mode 100644 index 0000000..9568190 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/category/AvailableColorsResponse.kt @@ -0,0 +1,7 @@ +package onku.backend.domain.notice.dto.category + +import onku.backend.domain.notice.enums.NoticeCategoryColor + +data class AvailableColorsResponse( + val colors: List +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryCreateRequest.kt b/src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryCreateRequest.kt new file mode 100644 index 0000000..e076eaa --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryCreateRequest.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.notice.dto.category + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import onku.backend.domain.notice.enums.NoticeCategoryColor + +data class NoticeCategoryCreateRequest( + @field:NotBlank(message = "카테고리 이름은 필수입니다.") + @field:Size(max = 6, message = "카테고리 이름은 7자 이상 등록할 수 없습니다.") + val name: String, + val color: NoticeCategoryColor +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryResponse.kt b/src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryResponse.kt new file mode 100644 index 0000000..430b8b0 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryResponse.kt @@ -0,0 +1,18 @@ +package onku.backend.domain.notice.dto.category + +import onku.backend.domain.notice.NoticeCategory +import onku.backend.domain.notice.enums.NoticeCategoryColor + +data class NoticeCategoryResponse( + val id: Long, + val name: String, + val color: NoticeCategoryColor +) { + companion object { + fun from(entity: NoticeCategory) = NoticeCategoryResponse( + id = entity.id!!, + name = entity.name, + color = entity.color + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryUpdateRequest.kt b/src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryUpdateRequest.kt new file mode 100644 index 0000000..1fcba93 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryUpdateRequest.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.notice.dto.category + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import onku.backend.domain.notice.enums.NoticeCategoryColor + +data class NoticeCategoryUpdateRequest( + @field:NotBlank(message = "카테고리 이름은 필수입니다.") + @field:Size(max = 6, message = "카테고리 이름은 7자 이상 등록할 수 없습니다.") + val name: String, + val color: NoticeCategoryColor +) diff --git a/src/main/kotlin/onku/backend/domain/notice/dto/notice/CategoryBadge.kt b/src/main/kotlin/onku/backend/domain/notice/dto/notice/CategoryBadge.kt new file mode 100644 index 0000000..174d426 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/CategoryBadge.kt @@ -0,0 +1,14 @@ +package onku.backend.domain.notice.dto.notice + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.notice.enums.NoticeCategoryColor + +@Schema(description = "공지에 표시되는 카테고리 정보") +data class CategoryBadge( + + @Schema(description = "카테고리 이름", example = "공지") + val name: String, + + @Schema(description = "카테고리 색상", example = "YELLOW") + val color: NoticeCategoryColor +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeCreateRequest.kt b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeCreateRequest.kt new file mode 100644 index 0000000..69ca933 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeCreateRequest.kt @@ -0,0 +1,9 @@ +package onku.backend.domain.notice.dto.notice + +data class NoticeCreateRequest( + val title: String, + val categoryIds: List, + val content: String, + val fileIds: List = emptyList() +) +typealias NoticeUpdateRequest = NoticeCreateRequest \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeDetailResponse.kt b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeDetailResponse.kt new file mode 100644 index 0000000..ab1aac9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeDetailResponse.kt @@ -0,0 +1,13 @@ +package onku.backend.domain.notice.dto.notice + +data class NoticeDetailResponse( + val id: Long, + val title: String?, + val categories: List, + val createdAt: String, + val content: String?, + val authorId: Long, + val authorName: String?, + val imageUrls: List, + val fileUrls: List +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeFileWithUrl.kt b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeFileWithUrl.kt new file mode 100644 index 0000000..093057b --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeFileWithUrl.kt @@ -0,0 +1,19 @@ +package onku.backend.domain.notice.dto.notice + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "공지 첨부파일 + presigned URL") +data class NoticeFileWithUrl( + + @Schema(description = "첨부파일 ID", example = "1") + val id: Long, + + @Schema(description = "첨부파일 다운로드 URL") + val url: String, + + @Schema(description = "첨부파일 크기", example = "123456") + val size: Long?, + + @Schema(description = "파일원본 이름", example = "string.png") + val originalFileName : String, +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListItemResponse.kt b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListItemResponse.kt new file mode 100644 index 0000000..76b94b2 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListItemResponse.kt @@ -0,0 +1,45 @@ +package onku.backend.domain.notice.dto.notice + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.notice.enums.NoticeStatus + +@Schema(description = "공지 한 개 응답") +data class NoticeListItemResponse( + + @Schema(description = "공지 ID", example = "1") + val id: Long, + + @Schema(description = "공지 제목", example = "11월 3주차 온큐 공지") + val title: String?, + + @Schema(description = "공지 내용(일부 또는 전체)", example = "이번 주 스터디 공지입니다.") + val content: String?, + + @Schema(description = "작성자 회원 ID", example = "10") + val authorId: Long, + + @Schema(description = "작성자 이름", example = "김온큐") + val authorName: String?, + + @Schema(description = "공지에 연결된 카테고리 뱃지 리스트") + val categories: List, + + @Schema( + description = "공지 작성(또는 게시)일시, 포맷: YYYY/MM/DD HH:mm", + example = "2025/11/15 13:30" + ) + val createdAt: String, + + @Schema( + description = "공지 상태 (예: DRAFT, PUBLISHED 등)", + example = "PUBLISHED", + nullable = true + ) + val status: NoticeStatus?, + + @Schema(description = "공지에 첨부된 이미지 파일들의 URL 리스트") + val imageUrls: List, + + @Schema(description = "공지에 첨부된 일반 파일들의 URL 리스트") + val fileUrls: List +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/dto/notice/PresignedUploadResponse.kt b/src/main/kotlin/onku/backend/domain/notice/dto/notice/PresignedUploadResponse.kt new file mode 100644 index 0000000..06c3222 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/PresignedUploadResponse.kt @@ -0,0 +1,16 @@ +package onku.backend.domain.notice.dto.notice + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "공지 파일 업로드를 위한 프리사인드 URL 응답") +data class PresignedUploadResponse( + + @Schema(description = "업로드할 파일의 ID", example = "3") + val fileId: Long, + + @Schema( + description = "S3 업로드용 프리사인드 URL (PUT 요청에 사용)", + example = "https://..." + ) + val presignedUrl: String +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt b/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt new file mode 100644 index 0000000..bdba5bd --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt @@ -0,0 +1,50 @@ +package onku.backend.domain.notice.enums + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = """ +공지 카테고리 색상: +- RED (빨강) +- ORANGE (주황) +- YELLOW (노랑) +- GREEN (초록) +- LIGHT_GREEN (연두) +- TEAL (청록) +- BLUE (파랑) +- PURPLE (보라) +- PINK (분홍) +- BROWN (갈색) +""" +) +enum class NoticeCategoryColor { + @Schema(description = "빨강") + RED, + + @Schema(description = "주황") + ORANGE, + + @Schema(description = "노랑") + YELLOW, + + @Schema(description = "초록") + GREEN, + + @Schema(description = "연두") + LIGHT_GREEN, + + @Schema(description = "청록") + TEAL, + + @Schema(description = "파랑") + BLUE, + + @Schema(description = "보라") + PURPLE, + + @Schema(description = "분홍") + PINK, + + @Schema(description = "갈색") + BROWN; +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/enums/NoticeStatus.kt b/src/main/kotlin/onku/backend/domain/notice/enums/NoticeStatus.kt new file mode 100644 index 0000000..e7f895b --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/enums/NoticeStatus.kt @@ -0,0 +1,5 @@ +package onku.backend.domain.notice.enums + +enum class NoticeStatus { + SCHEDULED, DRAFT, PUBLISHED +} diff --git a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeAttachmentRepository.kt b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeAttachmentRepository.kt new file mode 100644 index 0000000..2bf20ca --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeAttachmentRepository.kt @@ -0,0 +1,7 @@ +package onku.backend.domain.notice.repository + +import onku.backend.domain.notice.NoticeAttachment +import org.springframework.data.jpa.repository.JpaRepository + +interface NoticeAttachmentRepository : JpaRepository { +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeCategoryRepository.kt b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeCategoryRepository.kt new file mode 100644 index 0000000..f29a1ea --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeCategoryRepository.kt @@ -0,0 +1,24 @@ +package onku.backend.domain.notice.repository + +import onku.backend.domain.notice.NoticeCategory +import onku.backend.domain.notice.enums.NoticeCategoryColor +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface NoticeCategoryRepository : JpaRepository { + fun existsByName(name: String): Boolean + fun existsByColor(color: NoticeCategoryColor): Boolean + + @Query( + """ + select case when count(nc) > 0 then true else false end + from Notice n + join n.categories nc + where nc.id = :categoryId + """ + ) + fun isCategoryLinkedToAnyNotice(categoryId: Long): Boolean + + @Query("select nc from NoticeCategory nc order by nc.id asc") + fun findAllOrderByIdAsc(): List +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt new file mode 100644 index 0000000..dbb9b99 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt @@ -0,0 +1,26 @@ +package onku.backend.domain.notice.repository + +import onku.backend.domain.notice.Notice +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface NoticeRepository : JpaRepository { + + @EntityGraph(attributePaths = ["categories", "attachments"]) + fun findAllByOrderByPublishedAtDescIdDesc(pageable: Pageable): Page + + @EntityGraph(attributePaths = ["categories", "attachments"]) + fun findDistinctByCategoriesIdOrderByPublishedAtDescIdDesc( + categoryId: Long, + pageable: Pageable + ): Page + + @EntityGraph(attributePaths = ["categories", "attachments"]) + fun findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseOrderByPublishedAtDescIdDesc( + titleKeyword: String, + contentKeyword: String, + pageable: Pageable + ): Page +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/service/NoticeAttachmentService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeAttachmentService.kt new file mode 100644 index 0000000..dc7de99 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeAttachmentService.kt @@ -0,0 +1,50 @@ +package onku.backend.domain.notice.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import onku.backend.domain.member.Member +import onku.backend.domain.notice.NoticeAttachment +import onku.backend.domain.notice.dto.notice.PresignedUploadResponse +import onku.backend.domain.notice.repository.NoticeAttachmentRepository +import onku.backend.global.s3.dto.GetS3UrlDto +import onku.backend.global.s3.enums.FolderName +import onku.backend.global.s3.enums.UploadOption +import onku.backend.global.s3.service.S3Service + +@Service +@Transactional(readOnly = true) +class NoticeAttachmentService( + private val noticeAttachmentRepository: NoticeAttachmentRepository, + private val s3Service: S3Service +) { + + @Transactional + fun prepareUpload( + currentMember: Member, + filename: String, + fileType: UploadOption, + fileSize: Long, + ): PresignedUploadResponse { + + val put: GetS3UrlDto = s3Service.getPostS3Url( + memberId = currentMember.id!!, + filename = filename, + folderName = FolderName.NOTICE.name, + option = fileType + ) + + val file = noticeAttachmentRepository.save( + NoticeAttachment( + notice = null, + s3Key = put.key, + attachmentType = fileType, + attachmentSize = fileSize + ) + ) + + return PresignedUploadResponse( + fileId = file.id!!, + presignedUrl = put.preSignedUrl + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/service/NoticeCategoryService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeCategoryService.kt new file mode 100644 index 0000000..2d2c8d8 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeCategoryService.kt @@ -0,0 +1,75 @@ +package onku.backend.domain.notice.service + +import jakarta.transaction.Transactional +import onku.backend.domain.notice.NoticeCategory +import org.springframework.stereotype.Service +import onku.backend.domain.notice.dto.category.* +import onku.backend.domain.notice.enums.NoticeCategoryColor +import onku.backend.domain.notice.NoticeErrorCode +import onku.backend.domain.notice.repository.NoticeCategoryRepository +import onku.backend.global.exception.CustomException + +@Service +class NoticeCategoryService( + private val categoryRepository: NoticeCategoryRepository +) { + @Transactional + fun create(req: NoticeCategoryCreateRequest): NoticeCategoryResponse { + if (categoryRepository.existsByName(req.name)) { + throw CustomException(NoticeErrorCode.CATEGORY_NAME_DUPLICATE) + } + if (categoryRepository.existsByColor(req.color)) { + throw CustomException(NoticeErrorCode.CATEGORY_COLOR_DUPLICATE) + } + + val saved = categoryRepository.save( + NoticeCategory( + name = req.name.trim(), + color = req.color + ) + ) + return NoticeCategoryResponse.from(saved) + } + + @Transactional + fun update(categoryId: Long, req: NoticeCategoryUpdateRequest): NoticeCategoryResponse { + val category = categoryRepository.findById(categoryId) + .orElseThrow { CustomException(NoticeErrorCode.CATEGORY_NOT_FOUND) } + + // 이름이 변경되면 중복 체크 + if (category.name != req.name && categoryRepository.existsByName(req.name)) { + throw CustomException(NoticeErrorCode.CATEGORY_NAME_DUPLICATE) + } + // 색상이 변경되면 중복 체크 + if (category.color != req.color && categoryRepository.existsByColor(req.color)) { + throw CustomException(NoticeErrorCode.CATEGORY_COLOR_DUPLICATE) + } + + category.name = req.name.trim() + category.color = req.color + return NoticeCategoryResponse.from(category) + } + + @Transactional + fun delete(categoryId: Long) { + if (!categoryRepository.existsById(categoryId)) { + throw CustomException(NoticeErrorCode.CATEGORY_NOT_FOUND) + } + if (categoryRepository.isCategoryLinkedToAnyNotice(categoryId)) { + throw CustomException(NoticeErrorCode.CATEGORY_LINKED_NOT_DELETABLE) + } + categoryRepository.deleteById(categoryId) + } + + @Transactional + fun list(): List = + categoryRepository.findAllOrderByIdAsc().map(NoticeCategoryResponse::from) + + @Transactional + fun availableColors(): AvailableColorsResponse { + val used = categoryRepository.findAll().map { it.color }.toSet() + val all = NoticeCategoryColor.entries.toSet() + val available = (all - used).sortedBy { it.name } + return AvailableColorsResponse(available) + } +} diff --git a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt new file mode 100644 index 0000000..080966f --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt @@ -0,0 +1,208 @@ +package onku.backend.domain.notice.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import onku.backend.domain.member.Member +import onku.backend.domain.notice.Notice +import onku.backend.domain.notice.NoticeErrorCode +import onku.backend.domain.notice.NoticeAttachment +import onku.backend.domain.notice.dto.notice.* +import onku.backend.domain.notice.repository.NoticeCategoryRepository +import onku.backend.domain.notice.repository.NoticeAttachmentRepository +import onku.backend.domain.notice.repository.NoticeRepository +import onku.backend.domain.notice.util.NoticeDtoMapper +import onku.backend.global.exception.CustomException +import onku.backend.global.page.PageResponse +import onku.backend.global.s3.enums.UploadOption +import onku.backend.global.s3.service.S3Service +import java.time.LocalDateTime +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort + +@Service +@Transactional(readOnly = true) +class NoticeService( + private val noticeRepository: NoticeRepository, + private val categoryRepository: NoticeCategoryRepository, + private val noticeAttachmentRepository: NoticeAttachmentRepository, + private val s3Service: S3Service +) { + + fun list( + currentMember: Member, + page: Int, + size: Int, + categoryId: Long? + ): PageResponse { + + val memberId = currentMember.id!! + + val pageable = PageRequest.of( + page, + size, + Sort.by( + Sort.Order.desc("publishedAt"), + Sort.Order.desc("id") + ) + ) + + val noticePage = if (categoryId == null) { + noticeRepository.findAllByOrderByPublishedAtDescIdDesc(pageable) + } else { + // 해당 카테고리를 포함하는 공지 검색 + noticeRepository.findDistinctByCategoriesIdOrderByPublishedAtDescIdDesc( + categoryId, + pageable + ) + } + val dtoPage = noticePage.map { n -> + val (imageFiles, fileFiles) = splitPresignedUrls(memberId, n.attachments) + NoticeDtoMapper.toListItem(n, imageFiles, fileFiles) + } + return PageResponse.from(dtoPage) + } + + fun search( + keyword: String, + page: Int, + size: Int + ): PageResponse { + val memberId = 0L + val pageable = PageRequest.of( + page, + size, + Sort.by( + Sort.Order.desc("publishedAt"), + Sort.Order.desc("id") + ) + ) + val trimmedKeyword = keyword.trim() + val noticePage = if (trimmedKeyword.isBlank()) { + noticeRepository.findAllByOrderByPublishedAtDescIdDesc(pageable) + } else { // 제목 OR 내용에 keyword 포함되는 공지 검색 + noticeRepository + .findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseOrderByPublishedAtDescIdDesc( + trimmedKeyword, + trimmedKeyword, + pageable + ) + } + val dtoPage = noticePage.map { n -> + val (imageFiles, fileFiles) = splitPresignedUrls(memberId, n.attachments) + NoticeDtoMapper.toListItem(n, imageFiles, fileFiles) + } + return PageResponse.from(dtoPage) + } + + fun get(noticeId: Long, currentMember: Member): NoticeDetailResponse { + val n = noticeRepository.findById(noticeId) + .orElseThrow { CustomException(NoticeErrorCode.NOTICE_NOT_FOUND) } + + val memberId = currentMember.id!! + val (imageFiles, fileFiles) = splitPresignedUrls(memberId, n.attachments) + + return NoticeDtoMapper.toDetail(n, imageFiles, fileFiles) + } + + @Transactional + fun create(currentMember: Member, req: NoticeCreateRequest): NoticeDetailResponse { + val categories = categoryRepository.findAllById(req.categoryIds) + if (categories.size != req.categoryIds.size) { + throw CustomException(NoticeErrorCode.CATEGORY_NOT_FOUND) + } + + val notice = noticeRepository.save( + Notice( + member = currentMember, + title = req.title, + content = req.content, + publishedAt = LocalDateTime.now() + ) + ) + notice.categories.addAll(categories.toSet()) + + if (req.fileIds.isNotEmpty()) { + val files = noticeAttachmentRepository.findAllById(req.fileIds) + files.forEach { notice.addFile(it) } + } + + val memberId = currentMember.id!! + val (imageFiles, fileFiles) = splitPresignedUrls(memberId, notice.attachments) + + return NoticeDtoMapper.toDetail(notice, imageFiles, fileFiles) + } + + @Transactional + fun update(noticeId: Long, currentMember: Member, req: NoticeUpdateRequest): NoticeDetailResponse { + val notice = noticeRepository.findById(noticeId) + .orElseThrow { CustomException(NoticeErrorCode.NOTICE_NOT_FOUND) } + + val categories = categoryRepository.findAllById(req.categoryIds) + if (categories.size != req.categoryIds.size) { + throw CustomException(NoticeErrorCode.CATEGORY_NOT_FOUND) + } + + notice.title = req.title + notice.content = req.content + notice.categories.clear() + notice.categories.addAll(categories.toSet()) + + val newFiles: List = + if (req.fileIds.isNotEmpty()) { + noticeAttachmentRepository.findAllById(req.fileIds) + } else { + emptyList() + } + notice.clearFiles() + newFiles.forEach { notice.addFile(it) } + + val memberId = currentMember.id!! + val (imageFiles, fileFiles) = splitPresignedUrls(memberId, notice.attachments) + + return NoticeDtoMapper.toDetail(notice, imageFiles, fileFiles) + } + + @Transactional + fun delete(noticeId: Long) { + val notice = noticeRepository.findById(noticeId) + .orElseThrow { CustomException(NoticeErrorCode.NOTICE_NOT_FOUND) } + + val keys = notice.attachments.map { it.s3Key }.toList() + if (keys.isNotEmpty()) { + s3Service.deleteObjectsNow(keys) + } + noticeRepository.delete(notice) + } + + private fun presignGet(memberId: Long, key: String) = + s3Service.getGetS3Url(memberId = memberId, key = key) + + private fun splitPresignedUrls( + memberId: Long, + files: List + ): Pair, List> { + val (imageFiles, otherFiles) = files.partition { it.attachmentType == UploadOption.IMAGE } + + val imageDtos = imageFiles.map { file -> + val preSignGetDto = presignGet(memberId, file.s3Key) + NoticeFileWithUrl( + id = file.id!!, + url = preSignGetDto.preSignedUrl, + size = file.attachmentSize, + originalFileName = preSignGetDto.originalName + ) + } + + val fileDtos = otherFiles.map { file -> + val preSignGetDto = presignGet(memberId, file.s3Key) + NoticeFileWithUrl( + id = file.id!!, + url = presignGet(memberId, file.s3Key).preSignedUrl, + size = file.attachmentSize, + originalFileName = preSignGetDto.originalName + ) + } + + return imageDtos to fileDtos + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/util/NoticeDtoMapper.kt b/src/main/kotlin/onku/backend/domain/notice/util/NoticeDtoMapper.kt new file mode 100644 index 0000000..e651fd4 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/util/NoticeDtoMapper.kt @@ -0,0 +1,48 @@ +package onku.backend.domain.notice.util + +import onku.backend.domain.notice.Notice +import onku.backend.domain.notice.NoticeCategory +import onku.backend.domain.notice.dto.notice.* +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +private val fmtList = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm") +private val fmtDetail = DateTimeFormatter.ofPattern("MM/dd HH:mm") + +object NoticeDtoMapper { + fun toCategoryBadge(c: NoticeCategory) = + CategoryBadge(name = c.name, color = c.color) + + fun toListItem( + n: Notice, + imageFiles: List, + fileFiles: List + ) = NoticeListItemResponse( + id = n.id!!, + title = n.title, + content = n.content, + authorId = n.member.id!!, + authorName = n.member.memberProfile?.name, + categories = n.categories.map(::toCategoryBadge), + createdAt = (n.publishedAt ?: LocalDateTime.now()).format(fmtList), + status = n.status, + imageUrls = imageFiles, + fileUrls = fileFiles + ) + + fun toDetail( + n: Notice, + imageFiles: List, + fileFiles: List + ) = NoticeDetailResponse( + id = n.id!!, + title = n.title, + categories = n.categories.map(::toCategoryBadge), + createdAt = (n.publishedAt ?: LocalDateTime.now()).format(fmtDetail), + content = n.content, + authorId = n.member.id!!, + authorName = n.member.memberProfile?.name, + imageUrls = imageFiles, + fileUrls = fileFiles + ) +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/ManualPoint.kt b/src/main/kotlin/onku/backend/domain/point/ManualPoint.kt new file mode 100644 index 0000000..d911ade --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/ManualPoint.kt @@ -0,0 +1,25 @@ +package onku.backend.domain.point + +import jakarta.persistence.* +import onku.backend.domain.member.Member + +@Entity +class ManualPoint( + @Id + @Column(name = "member_id") + val memberId: Long? = null, + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "member_id") + val member: Member, + + @Column(name = "study_points") + var studyPoints: Int? = 0, + + @Column(name = "kupporters_points") + var kupportersPoints: Int? = 0, + + @Column(name = "memo", columnDefinition = "TEXT") + var memo: String? = null +) diff --git a/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt new file mode 100644 index 0000000..a7ba461 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt @@ -0,0 +1,163 @@ +package onku.backend.domain.point + +import jakarta.persistence.* +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.member.Member +import onku.backend.domain.point.enums.ManualPointType +import onku.backend.domain.point.enums.PointCategory +import onku.backend.global.entity.BaseEntity +import java.time.LocalDateTime +import java.time.LocalTime + +@Entity +@Table( + indexes = [ + Index(name = "idx_member_point_member_date", columnList = "member_id, occurred_at"), + Index(name = "idx_member_point_category", columnList = "category"), + Index(name = "idx_member_point_type", columnList = "type") + ] +) +class MemberPointHistory( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "record_id") + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + val member: Member, + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false) + val category: PointCategory, + + @Column(name = "type", nullable = false) + var type: String, + + @Column(name = "points", nullable = false) + var points: Int, + + @Column(name = "occurred_at", nullable = false) + var occurredAt: LocalDateTime, + + @Column(name = "week") + val week: Long? = null, + + @Column(name = "attendance_time") + val attendanceTime: LocalTime? = null, + + @Column(name = "early_leave_time") + val earlyLeaveTime: LocalTime? = null +) : BaseEntity() { + + companion object { + fun ofAttendance( + member: Member, + status: AttendancePointType, + occurredAt: LocalDateTime, + week: Long, + time: LocalTime? = null + ): MemberPointHistory { + return when (status) { + AttendancePointType.EARLY_LEAVE -> MemberPointHistory( + member = member, + category = PointCategory.ATTENDANCE, + type = status.name, + points = status.points, + occurredAt = occurredAt, + week = week, + earlyLeaveTime = time + ) + AttendancePointType.PRESENT, + AttendancePointType.PRESENT_HOLIDAY, + AttendancePointType.LATE -> MemberPointHistory( + member = member, + category = PointCategory.ATTENDANCE, + type = status.name, + points = status.points, + occurredAt = occurredAt, + week = week, + attendanceTime = time + ) + AttendancePointType.EXCUSED, + AttendancePointType.ABSENT, + AttendancePointType.ABSENT_WITH_DOC, + AttendancePointType.ABSENT_WITH_CAUSE -> MemberPointHistory( + member = member, + category = PointCategory.ATTENDANCE, + type = status.name, + points = status.points, + occurredAt = occurredAt, + week = week + ) + } + } + + fun ofAttendanceUpdate( + member: Member, + status: AttendancePointType, + occurredAt: LocalDateTime, + week: Long?, + diffPoint: Int, // AttendancePointType에 지정된 points가 아닌, 기존 status의 point와 새로이 바뀐 status의 point의 차이를 계산해서 Int 형태로 넣는 diff! + time: LocalTime? = null + ): MemberPointHistory { + return when (status) { + AttendancePointType.EARLY_LEAVE -> MemberPointHistory( + member = member, + category = PointCategory.ATTENDANCE, + type = status.name, + points = diffPoint, + occurredAt = occurredAt, + week = week, + earlyLeaveTime = time + ) + AttendancePointType.PRESENT, + AttendancePointType.PRESENT_HOLIDAY, + AttendancePointType.LATE -> MemberPointHistory( + member = member, + category = PointCategory.ATTENDANCE, + type = status.name, + points = diffPoint, + occurredAt = occurredAt, + week = week, + attendanceTime = time + ) + AttendancePointType.EXCUSED, + AttendancePointType.ABSENT, + AttendancePointType.ABSENT_WITH_DOC, + AttendancePointType.ABSENT_WITH_CAUSE -> MemberPointHistory( + member = member, + category = PointCategory.ATTENDANCE, + type = status.name, + points = diffPoint, + occurredAt = occurredAt, + week = week + ) + } + } + + fun ofManual( + member: Member, + manualType: ManualPointType, + occurredAt: LocalDateTime, + points: Int + ): MemberPointHistory { + return MemberPointHistory( + member = member, + category = PointCategory.MANUAL, + type = manualType.name, + points = points, + occurredAt = occurredAt + ) + } + } + + fun updateAttendancePointType( + status: AttendancePointType, + occurredAt: LocalDateTime, + ) { + this.type = status.name + this.points = status.points + this.occurredAt = occurredAt + } +} diff --git a/src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt b/src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt new file mode 100644 index 0000000..f3606b1 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt @@ -0,0 +1,134 @@ +package onku.backend.domain.point.controller.manager + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import onku.backend.domain.point.dto.* +import onku.backend.domain.point.service.AdminPointCommandService +import onku.backend.domain.point.service.AdminPointService +import onku.backend.global.page.PageResponse +import onku.backend.global.response.SuccessResponse +import org.springframework.http.ResponseEntity +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* +import java.time.YearMonth + +@RestController +@RequestMapping("/api/v1/points/manage") +@Tag( + name = "[MANAGEMENT] 운영진 상벌점", + description = "운영진 상벌점 대시보드 조회 API" +) +@Validated +class PointManagerController( + private val adminPointsService: AdminPointService, + private val commandService: AdminPointCommandService +) { + + @GetMapping("/overview") + @Operation( + summary = "운영진 상벌점 목록 조회", + description = "이름, 파트, 월별 출결 점수(출결만 반영), 월별 큐픽 참여 여부, TF/운영진 여부, 연락/학교/학과, 스터디/큐포터즈 점수, 메모를 멤버 단위로 페이징하여 반환" + ) + fun overview( + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int + ): ResponseEntity>> { + val safePage = if (page < 1) 0 else page - 1 + val year = 2025 + val body = adminPointsService.getAdminOverview(year, safePage, size) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @PatchMapping("/study") + @Operation(summary = "스터디 점수 수정", description = "memberId와 studyPoints를 받아 수정합니다.") + fun updateStudyPoints(@RequestBody @Valid req: UpdateStudyPointsRequest) + : ResponseEntity> { + val result = commandService.updateStudyPoints(req.memberId!!, req.studyPoints!!) + return ResponseEntity.ok(SuccessResponse.ok(result)) + } + + @PatchMapping("/kupporters") + @Operation(summary = "큐포터즈 점수 수정", description = "memberId와 kupportersPoints를 받아 수정합니다.") + fun updateKupportersPoints(@RequestBody @Valid req: UpdateKupportersPointsRequest) + : ResponseEntity> { + val result = commandService.updateKupportersPoints(req.memberId!!, req.kuportersPoints!!) + return ResponseEntity.ok(SuccessResponse.ok(result)) + } + + @PatchMapping("/memo") + @Operation(summary = "메모 수정", description = "memberId와 memo를 받아 수정합니다.") + fun updateMemo(@RequestBody @Valid req: UpdateMemoRequest) + : ResponseEntity> { + val result = commandService.updateMemo(req.memberId!!, req.memo!!) + return ResponseEntity.ok(SuccessResponse.ok(result)) + } + + @PatchMapping("/is-tf") + @Operation(summary = "TF 여부 토글", description = "memberId만 받아 현재 TF 여부를 반전시킵니다.") + fun updateIsTf(@RequestBody @Valid req: ToggleMemberRequest) + : ResponseEntity> { + val isTf = commandService.updateIsTf(req.memberId!!) + val result = IsTfResult(memberId = req.memberId!!, isTf = isTf) + return ResponseEntity.ok(SuccessResponse.ok(result)) + } + + @PatchMapping("/is-staff") + @Operation(summary = "운영진(Staff) 여부 토글", description = "memberId만 받아 현재 Staff 여부를 반전시킵니다.") + fun updateIsStaff(@RequestBody @Valid req: ToggleMemberRequest) + : ResponseEntity> { + val isStaff = commandService.updateIsStaff(req.memberId!!) + val result = IsStaffResult(memberId = req.memberId!!, isStaff = isStaff) + return ResponseEntity.ok(SuccessResponse.ok(result)) + } + + @PatchMapping("/kupick") + @Operation( + summary = "이번 달 큐픽 제출여부 수정 토글", + description = "memberId, YearMonth값을 받아 이번 달 큐픽 승인 여부를 반전시킵니다. 제출 레코드가 없으면 생성합니다." + ) + fun updateKupick(@RequestBody @Valid req: ToggleMemberRequest) + : ResponseEntity> { + val ym = YearMonth.parse(req.yearMonth) + val result = commandService.updateKupickApproval( + memberId = req.memberId, + targetYm = ym + ) + return ResponseEntity.ok(SuccessResponse.ok(result)) + } + + @GetMapping("/monthly") + @Operation( + summary = "월간 출석 현황 [운영진]", + description = "year, month, page, size를 받아 멤버별 [date, attendanceId, status, point] 목록을 페이징으로 반환" + ) + fun getMonthly( + @RequestParam @Min(8) @Max(12) month: Int, + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int, + ): ResponseEntity> { + val safePage = if (page < 1) 0 else page - 1 + val year = 2025 + val body = adminPointsService.getMonthlyPaged(year, month, safePage, size) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } + + @PatchMapping("/monthly") + @Operation( + summary = "월별 출석 상태 수정 [운영진]", + description = "attendanceId, memberId, status를 받아 출석 상태를 수정하고, 상벌점 변동을 기록합니다." + ) + fun updateMonthly( + @RequestBody @Valid req: UpdateAttendanceStatusRequest + ): ResponseEntity> { + + val result = commandService.updateAttendanceAndHistory( + attendanceId = req.attendanceId, + memberId = req.memberId, + newStatus = req.status + ) + return ResponseEntity.ok(SuccessResponse.ok(result)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/controller/user/MemberPointController.kt b/src/main/kotlin/onku/backend/domain/point/controller/user/MemberPointController.kt new file mode 100644 index 0000000..0d42fdb --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/controller/user/MemberPointController.kt @@ -0,0 +1,39 @@ +package onku.backend.domain.point.controller.user + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.member.Member +import onku.backend.domain.point.dto.MemberPointHistoryResponse +import onku.backend.domain.point.service.MemberPointHistoryService +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.response.SuccessResponse +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/points") +@Tag( + name = "사용자 상벌점", + description = "사용자 상벌점 조회 API" +) +class MemberPointController( + private val memberPointService: MemberPointHistoryService +) { + @GetMapping("/history") + @Operation( + summary = "사용자 상/벌점 이력 조회", + description = "회원의 상/벌점 누적 합 및 날짜순(최신순) 이력을 페이징하여 반환" + ) + fun history( + @CurrentMember member: Member, + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int + ): ResponseEntity> { + val safePage = if (page < 1) 0 else page - 1 + val body = memberPointService.getHistory(member, safePage, size) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } +} diff --git a/src/main/kotlin/onku/backend/domain/point/converter/MemberPointConverter.kt b/src/main/kotlin/onku/backend/domain/point/converter/MemberPointConverter.kt new file mode 100644 index 0000000..d8bc4cc --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/converter/MemberPointConverter.kt @@ -0,0 +1,19 @@ +package onku.backend.domain.point.converter + +import onku.backend.domain.point.MemberPointHistory +import java.time.format.DateTimeFormatter + +object MemberPointConverter { + private val dateFmt = DateTimeFormatter.ofPattern("MM/dd") + private val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + + fun toResponse(r: MemberPointHistory): onku.backend.domain.point.dto.MemberPointHistory = + onku.backend.domain.point.dto.MemberPointHistory( + date = r.occurredAt.toLocalDate().format(dateFmt), + type = r.type, + points = r.points, + week = r.week, + attendanceTime = r.attendanceTime?.format(timeFmt), + earlyLeaveTime = r.earlyLeaveTime?.format(timeFmt) + ) +} diff --git a/src/main/kotlin/onku/backend/domain/point/dto/AdminPointOverviewDto.kt b/src/main/kotlin/onku/backend/domain/point/dto/AdminPointOverviewDto.kt new file mode 100644 index 0000000..a6e1d9a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/AdminPointOverviewDto.kt @@ -0,0 +1,21 @@ +package onku.backend.domain.point.dto + +import onku.backend.domain.member.enums.Part + +data class AdminPointOverviewDto( + val memberId: Long, + val name: String?, + val part: Part, + val phoneNumber: String?, + val school: String?, + val major: String?, + val isTf: Boolean, + val isStaff: Boolean, + + val attendanceMonthlyTotals: Map, // 월별 출석 점수 + val kupickParticipation: Map, // 월별 큐픽 참여 여부 + + val studyPoints: Int, + val kuportersPoints: Int, + val memo: String? +) diff --git a/src/main/kotlin/onku/backend/domain/point/dto/MemberPointHistoryResponse.kt b/src/main/kotlin/onku/backend/domain/point/dto/MemberPointHistoryResponse.kt new file mode 100644 index 0000000..8c17408 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/MemberPointHistoryResponse.kt @@ -0,0 +1,25 @@ +package onku.backend.domain.point.dto + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "단일 상/벌점 레코드 응답") +data class MemberPointHistory( + val date: String, + val type: String, + val points: Int, + val week: Long? = null, + val attendanceTime: String? = null, + val earlyLeaveTime: String? = null +) + +@Schema(description = "사용자 상/벌점 이력 응답") +data class MemberPointHistoryResponse( + val memberId: Long, + val name: String? = null, + val plusPoints: Int, + val minusPoints: Int, + val totalPoints: Int, + val records: List, + val totalPages: Int, + val isLastPage: Boolean +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/MonthlyAttendancePageResponse.kt b/src/main/kotlin/onku/backend/domain/point/dto/MonthlyAttendancePageResponse.kt new file mode 100644 index 0000000..07b74b8 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/MonthlyAttendancePageResponse.kt @@ -0,0 +1,25 @@ +package onku.backend.domain.point.dto + +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.global.page.PageResponse +import java.time.LocalDate + +data class AttendanceRecordDto( + val date: LocalDate, + val attendanceId: Long?, + val status: AttendancePointType?, + val point: Int? +) + +data class MemberMonthlyAttendanceDto( + val memberId: Long, + val name: String, + val records: List +) + +data class MonthlyAttendancePageResponse( + val year: Int, + val month: Int, + val sessionDates: List, + val members: PageResponse +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateAttendanceStatusRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateAttendanceStatusRequest.kt new file mode 100644 index 0000000..449eb98 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateAttendanceStatusRequest.kt @@ -0,0 +1,13 @@ +package onku.backend.domain.point.dto + +import jakarta.validation.constraints.NotNull +import onku.backend.domain.attendance.enums.AttendancePointType + +data class UpdateAttendanceStatusRequest( + @field:NotNull + val attendanceId: Long, + @field:NotNull + val memberId: Long, + @field:NotNull + val status: AttendancePointType +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateAttendanceStatusResponse.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateAttendanceStatusResponse.kt new file mode 100644 index 0000000..4d5cd08 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateAttendanceStatusResponse.kt @@ -0,0 +1,14 @@ +package onku.backend.domain.point.dto + +import onku.backend.domain.attendance.enums.AttendancePointType +import java.time.LocalDateTime + +data class UpdateAttendanceStatusResponse( + val attendanceId: Long, + val memberId: Long, + val oldStatus: AttendancePointType, + val newStatus: AttendancePointType, + val diff: Int, + val week: Long?, + val occurredAt: LocalDateTime +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt new file mode 100644 index 0000000..05b4f1c --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt @@ -0,0 +1,31 @@ +package onku.backend.domain.point.dto + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern + +sealed interface UpdateManualPointRequest + +data class ToggleMemberRequest( + @field:NotNull + val memberId: Long, + + @field:NotBlank + @field:Pattern(regexp = """^\d{4}-(0[1-9]|1[0-2])$""", message = "yearMonth는 yyyy-MM 형식이어야 합니다.") + val yearMonth: String +) + +data class UpdateKupportersPointsRequest( + @field:NotNull val memberId: Long?, + @field:NotNull val kuportersPoints: Int? +) : UpdateManualPointRequest + +data class UpdateMemoRequest( + @field:NotNull val memberId: Long?, + @field:NotBlank val memo: String? +) : UpdateManualPointRequest + +data class UpdateStudyPointsRequest( + @field:NotNull val memberId: Long?, + @field:NotNull val studyPoints: Int? +) : UpdateManualPointRequest diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointResponse.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointResponse.kt new file mode 100644 index 0000000..8e85ddb --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointResponse.kt @@ -0,0 +1,40 @@ +package onku.backend.domain.point.dto + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "스터디 점수 수정 결과") +data class StudyPointsResult( + @Schema(example = "123") val memberId: Long, + @Schema(example = "7") val studyPoints: Int +) + +@Schema(description = "큐포터즈 점수 수정 결과") +data class KupportersPointsResult( + @Schema(example = "123") val memberId: Long, + @Schema(example = "3") val kupportersPoints: Int +) + +@Schema(description = "TF 여부 수정 결과") +data class IsTfResult( + @Schema(example = "123") val memberId: Long, + @Schema(example = "true") val isTf: Boolean +) + +@Schema(description = "운영진(Staff) 여부 수정 결과") +data class IsStaffResult( + @Schema(example = "123") val memberId: Long, + @Schema(example = "false") val isStaff: Boolean +) + +@Schema(description = "이번 달 큐픽 승인 상태 결과") +data class KupickApprovalResult( + @Schema(example = "123") val memberId: Long, + @Schema(example = "456") val kupickId: Long, + @Schema(example = "true") val isKupick: Boolean +) + +@Schema(description = "메모 수정 결과") +data class MemoResult( + @Schema(example = "123") val memberId: Long, + @Schema(example = "운영진 메모 최신 내용") val memo: String? +) diff --git a/src/main/kotlin/onku/backend/domain/point/enums/ManualPointType.kt b/src/main/kotlin/onku/backend/domain/point/enums/ManualPointType.kt new file mode 100644 index 0000000..dcca059 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/enums/ManualPointType.kt @@ -0,0 +1,24 @@ +package onku.backend.domain.point.enums + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "점수 종류") +enum class ManualPointType( + @Schema(description = "각 종류의 활동별 점수 매핑") + val points: Int +) { + @Schema(description = "큐픽: 1점") + KUPICK(1), + + @Schema(description = "TF 활동: 2점") + TF(2), + + @Schema(description = "운영진 활동: 1점") + STAFF(1), + + @Schema(description = "스터디: 1점") + STUDY(1), + + @Schema(description = "큐포터즈: 1점") + KUPORTERS(1); +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/enums/PointCategory.kt b/src/main/kotlin/onku/backend/domain/point/enums/PointCategory.kt new file mode 100644 index 0000000..f6442ca --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/enums/PointCategory.kt @@ -0,0 +1,6 @@ +package onku.backend.domain.point.enums + +enum class PointCategory { + ATTENDANCE, // AttendancePointType + MANUAL // ManualPointType +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRepository.kt b/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRepository.kt new file mode 100644 index 0000000..4135f4e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRepository.kt @@ -0,0 +1,9 @@ +package onku.backend.domain.point.repository + +import onku.backend.domain.point.ManualPoint +import org.springframework.data.jpa.repository.JpaRepository + +interface ManualPointRepository : JpaRepository { + fun findByMemberIdIn(memberIds: Collection): List + fun findByMemberId(memberId: Long): ManualPoint? +} diff --git a/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt new file mode 100644 index 0000000..09e3064 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt @@ -0,0 +1,77 @@ +package onku.backend.domain.point.repository + +import onku.backend.domain.member.Member +import onku.backend.domain.point.MemberPointHistory +import onku.backend.domain.point.enums.PointCategory +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface MemberPointHistoryRepository : JpaRepository { + + fun findByMemberOrderByOccurredAtDesc(member: Member, pageable: Pageable): Page + + interface MemberPointSums { + fun getPlusPoints(): Long + fun getMinusPoints(): Long + fun getTotalPoints(): Long + } + + @Query( + """ + SELECT COALESCE(SUM(CASE WHEN r.points > 0 THEN r.points ELSE 0 END), 0) AS plusPoints, + COALESCE(SUM(CASE WHEN r.points < 0 THEN r.points ELSE 0 END), 0) AS minusPoints, + COALESCE(SUM(r.points), 0) AS totalPoints + FROM MemberPointHistory r + WHERE r.member = :member + """ + ) + fun sumPointsForMember(@Param("member") member: Member): MemberPointSums + + // 운영진 조회용 쿼리 + interface MonthlyAttendanceSumRow { + fun getMemberId(): Long + fun getMonth(): Int + fun getPoints(): Long + } + + @Query( + """ + SELECT r.member.id AS memberId, + function('month', r.occurredAt) AS month, + SUM(r.points) AS points + FROM MemberPointHistory r + WHERE r.member.id IN :memberIds + AND r.category = :category + AND r.occurredAt >= :start AND r.occurredAt < :end + GROUP BY r.member.id, function('month', r.occurredAt) + """ + ) + fun sumAttendanceByMemberAndMonth( + @Param("memberIds") memberIds: Collection, + @Param("category") category: PointCategory, + @Param("start") start: LocalDateTime, + @Param("end") end: LocalDateTime + ): List + + @Query( + """ + SELECT r + FROM MemberPointHistory r + WHERE r.member.id IN :memberIds + AND r.category = :category + AND r.occurredAt >= :start AND r.occurredAt < :end + """ + ) + fun findAttendanceByMemberIdsBetween( + @Param("memberIds") memberIds: Collection, + @Param("category") category: PointCategory, + @Param("start") start: LocalDateTime, + @Param("end") end: LocalDateTime + ): List + + fun findByWeekAndMember(week : Long, member: Member) : MemberPointHistory? +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt new file mode 100644 index 0000000..a353623 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt @@ -0,0 +1,249 @@ +package onku.backend.domain.point.service + +import onku.backend.domain.attendance.AttendanceErrorCode +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.kupick.Kupick +import onku.backend.domain.kupick.KupickErrorCode +import onku.backend.domain.kupick.repository.KupickRepository +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.point.ManualPoint +import onku.backend.domain.point.MemberPointHistory +import onku.backend.domain.point.dto.* +import onku.backend.domain.point.enums.ManualPointType +import onku.backend.domain.point.repository.ManualPointRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.YearMonth + +@Service +class AdminPointCommandService( + private val manualPointRecordRepository: ManualPointRepository, + private val memberRepository: MemberRepository, + private val kupickRepository: KupickRepository, + private val memberPointHistoryRepository: MemberPointHistoryRepository, + private val attendanceRepository: AttendanceRepository, + private val sessionRepository: SessionRepository, + private val clock: Clock +) { + + @Transactional + fun updateStudyPoints(memberId: Long, studyPoints: Int): StudyPointsResult { + val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) + val before = rec.studyPoints ?: 0 + val after = studyPoints + val diff = after - before + if (diff != 0) { + val now = LocalDateTime.now(clock) + memberPointHistoryRepository.save( + MemberPointHistory.ofManual( + member = rec.member, + manualType = ManualPointType.STUDY, + occurredAt = now, + points = diff + ) + ) + } + rec.studyPoints = after + manualPointRecordRepository.save(rec) + return StudyPointsResult(memberId = rec.member.id!!, studyPoints = after) + } + + @Transactional + fun updateKupportersPoints(memberId: Long, kupportersPoints: Int): KupportersPointsResult { + val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) + val before = rec.kupportersPoints ?: 0 + val after = kupportersPoints + val diff = after - before + if (diff != 0) { + val now = LocalDateTime.now(clock) + memberPointHistoryRepository.save( + MemberPointHistory.ofManual( + member = rec.member, + manualType = ManualPointType.KUPORTERS, + occurredAt = now, + points = diff + ) + ) + } + rec.kupportersPoints = after + manualPointRecordRepository.save(rec) + return KupportersPointsResult(memberId = rec.member.id!!, kupportersPoints = after) + } + + @Transactional + fun updateMemo(memberId: Long, memo: String): MemoResult { + val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) + rec.memo = memo + manualPointRecordRepository.save(rec) + return MemoResult(memberId = rec.member.id!!, memo = rec.memo) + } + + @Transactional + fun updateIsTf(memberId: Long): Boolean { + val member = memberRepository.findById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + val now = LocalDateTime.now(clock) + val newValue = !member.isTf + val delta = if (newValue) ManualPointType.TF.points else -ManualPointType.TF.points + + memberPointHistoryRepository.save( + MemberPointHistory.ofManual( + member = member, + manualType = ManualPointType.TF, + occurredAt = now, + points = delta + ) + ) + member.isTf = newValue + return newValue + } + + @Transactional + fun updateIsStaff(memberId: Long): Boolean { + val member = memberRepository.findById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + val now = LocalDateTime.now(clock) + val newValue = !member.isStaff + val diff = if (newValue) ManualPointType.STAFF.points else -ManualPointType.STAFF.points + + memberPointHistoryRepository.save( + MemberPointHistory.ofManual( + member = member, + manualType = ManualPointType.STAFF, + occurredAt = now, + points = diff + ) + ) + member.isStaff = newValue + return newValue + } + + @Transactional + fun updateKupickApproval(memberId: Long, targetYm: YearMonth): KupickApprovalResult { + val member = memberRepository.findById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + val now = LocalDateTime.now(clock) + + val startOfMonth = targetYm.atDay(1).atStartOfDay() + val startOfNextMonth = targetYm.plusMonths(1).atDay(1).atStartOfDay() + + val existing = kupickRepository.findThisMonthByMember( // 기존 기록 조회 + member = member, + start = startOfMonth, + end = startOfNextMonth + ) + + val target = existing ?: run { // 없으면 생성 + val created = Kupick.createKupick(member, startOfMonth) + kupickRepository.save(created) + } + + val newApproved = !target.approval + target.updateApproval(newApproved) + + val diff = if (newApproved) ManualPointType.KUPICK.points else -ManualPointType.KUPICK.points + memberPointHistoryRepository.save( + MemberPointHistory.ofManual( + member = member, + manualType = ManualPointType.KUPICK, + occurredAt = now, // MemberPointHistory 레코드에는 수정시각(현재)로 기록 + points = diff + ) + ) + + val savedId = kupickRepository.save(target).id + ?: throw CustomException(KupickErrorCode.KUPICK_SAVE_FAILED) + + return KupickApprovalResult( + memberId = member.id!!, + kupickId = savedId, + isKupick = newApproved + ) + } + + private fun newManualRecord(memberId: Long): ManualPoint { + val memberRef = runCatching { memberRepository.getReferenceById(memberId) } + .getOrElse { throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + return ManualPoint( + member = memberRef, + studyPoints = 0, + kupportersPoints = 0, + memo = null + ) + } + + @Transactional + fun updateAttendanceAndHistory( + attendanceId: Long, + memberId: Long, + newStatus: AttendancePointType + ): UpdateAttendanceStatusResponse { + val attendance = attendanceRepository.findById(attendanceId) + .orElseThrow { CustomException(AttendanceErrorCode.ATTENDANCE_NOT_FOUND) } + + if (attendance.memberId != memberId) { + throw CustomException(AttendanceErrorCode.INVALID_MEMBER_FOR_ATTENDANCE) + } + + val oldStatus = attendance.status + if (oldStatus == newStatus) { + val session = sessionRepository.findById(attendance.sessionId) + .orElseThrow { CustomException(SessionErrorCode.SESSION_NOT_FOUND) } + return UpdateAttendanceStatusResponse( + attendanceId = attendanceId, + memberId = memberId, + oldStatus = oldStatus, + newStatus = newStatus, + diff = 0, + week = session.week, + occurredAt = LocalDateTime.now(clock) + ) + } + + attendance.status = newStatus + attendanceRepository.save(attendance) + + val diff = newStatus.points - oldStatus.points + + val session = sessionRepository.findById(attendance.sessionId) + .orElseThrow { CustomException(SessionErrorCode.SESSION_NOT_FOUND) } + + if (diff != 0) { + val now = LocalDateTime.now(clock) + val member = memberRepository.findById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + memberPointHistoryRepository.save( + MemberPointHistory.ofAttendanceUpdate( + member = member, + status = newStatus, + occurredAt = now, + week = session.week, + diffPoint = diff, + time = null + ) + ) + } + return UpdateAttendanceStatusResponse( + attendanceId = attendanceId, + memberId = memberId, + oldStatus = oldStatus, + newStatus = newStatus, + diff = diff, + week = session.week, + occurredAt = LocalDateTime.now(clock) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt new file mode 100644 index 0000000..25e50be --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt @@ -0,0 +1,285 @@ +package onku.backend.domain.point.service + +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.kupick.repository.KupickRepository +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.point.dto.AdminPointOverviewDto +import onku.backend.domain.point.dto.AttendanceRecordDto +import onku.backend.domain.point.dto.MemberMonthlyAttendanceDto +import onku.backend.domain.point.dto.MonthlyAttendancePageResponse +import onku.backend.domain.point.enums.PointCategory +import onku.backend.domain.point.repository.ManualPointRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.page.PageResponse +import onku.backend.global.time.TimeRangeUtil +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDate +import java.time.LocalDateTime +import kotlin.math.max + +@Service +class AdminPointService( + private val memberProfileRepository: MemberProfileRepository, + private val kupickRepository: KupickRepository, + private val manualPointRecordRepository: ManualPointRepository, + private val sessionRepository: SessionRepository, + private val memberPointHistoryRepository: MemberPointHistoryRepository, + private val attendanceRepository: AttendanceRepository, + private val clock: Clock +) { + + @Transactional(readOnly = true) + fun getAdminOverview( + year: Int, + page: Int, + size: Int + ): PageResponse { + val safePageIndex = max(0, page) + val pageRequest = PageRequest.of(safePageIndex, size) + + // 멤버 조회 (파트/이름 정렬 보장) + val profilePage = memberProfileRepository.findAllByOrderByPartAscNameAsc(pageRequest) + val memberIds = profilePage.content.mapNotNull(MemberProfile::memberId) + if (memberIds.isEmpty()) return PageResponse.from(profilePage.map { emptyOverviewRow(it) }) + + // 조회 구간 설정: 8월 ~ 12월 + val augRange = TimeRangeUtil.monthRange(year, 8, clock.zone) + val decRange = TimeRangeUtil.monthRange(year, 12, clock.zone) + val startOfAug: LocalDateTime = augRange.startOfMonth + val endExclusive: LocalDateTime = decRange.startOfNextMonth + + // 출석 레코드 → 월별 포인트 합산 (MemberPointHistory 기반) + val monthlyAttendanceTotals: MutableMap> = + memberIds.associateWith { initMonthScoreMap() }.toMutableMap() + + memberPointHistoryRepository + .sumAttendanceByMemberAndMonth( + memberIds = memberIds, + category = PointCategory.ATTENDANCE, + start = startOfAug, + end = endExclusive + ) + .forEach { row -> + val mId = row.getMemberId() + val month = row.getMonth() + if (month in 8..12) { + val mapForMember = monthlyAttendanceTotals.getOrPut(mId) { initMonthScoreMap() } + mapForMember[month] = (mapForMember[month] ?: 0) + row.getPoints().toInt() + } + } + + // 큐픽 참여 여부 + val kupickParticipationByMember = + memberIds.associateWith { initMonthParticipationMap() }.toMutableMap() + + kupickRepository.findMemberMonthParticipation(memberIds, startOfAug, endExclusive) + .forEach { row -> + val memberId = (row[0] as Number).toLong() + val month = (row[1] as Number).toInt() + if (month in 8..12) { + kupickParticipationByMember[memberId]!![month] = true + } + } + val manualPointsByMember = manualPointRecordRepository.findByMemberIdIn(memberIds) + .associateBy { it.memberId!! } + + val dtoPage = profilePage.map { profile -> + val memberId = profile.memberId!! + val monthTotals = monthlyAttendanceTotals[memberId] ?: initMonthScoreMap() + val kupickMap = kupickParticipationByMember[memberId] ?: initMonthParticipationMap() + val manual = manualPointsByMember[memberId] + + AdminPointOverviewDto( + memberId = memberId, + name = profile.name, + part = profile.part, + phoneNumber = profile.phoneNumber, + school = profile.school, + major = profile.major, + isTf = profile.member.isTf, + isStaff = profile.member.isStaff, + attendanceMonthlyTotals = monthTotals.toSortedMap(), + kupickParticipation = kupickMap.toSortedMap(), + studyPoints = manual?.studyPoints ?: 0, + kuportersPoints = manual?.kupportersPoints ?: 0, + memo = manual?.memo + ) + } + + return PageResponse.from(dtoPage) + } + + private fun initMonthScoreMap(): MutableMap = + (8..12).associateWith { 0 }.toMutableMap() + + private fun initMonthParticipationMap(): MutableMap = + (8..12).associateWith { false }.toMutableMap() + + private fun emptyOverviewRow(profile: onku.backend.domain.member.MemberProfile): AdminPointOverviewDto { + return AdminPointOverviewDto( + memberId = profile.memberId!!, + name = profile.name, + part = profile.part, + phoneNumber = profile.phoneNumber, + school = profile.school, + major = profile.major, + isTf = profile.member.isTf, + isStaff = profile.member.isStaff, + attendanceMonthlyTotals = (8..12).associateWith { 0 }, + kupickParticipation = (8..12).associateWith { false }, + studyPoints = 0, + kuportersPoints = 0, + memo = null + ) + } + + @Transactional(readOnly = true) + fun getMonthlyPaged( + year: Int, + month: Int, + page: Int, + size: Int + ): MonthlyAttendancePageResponse { + val monthRange = TimeRangeUtil.monthRange(year, month, clock.zone) + val start: LocalDateTime = monthRange.startOfMonth + val end: LocalDateTime = monthRange.startOfNextMonth + + val startDate: LocalDate = start.toLocalDate() + val endDateInclusive: LocalDate = end.minusNanos(1).toLocalDate() + + val sessionsInMonth: List = + sessionRepository.findByStartDateBetween(startDate, endDateInclusive) + + val sessionDateById: Map = sessionsInMonth + .associate { session -> session.id!! to session.startDate } + + val sessionDates: List = sessionsInMonth + .map { it.startDate } + .distinct() + .sorted() + val sessionDays: List = sessionDates.map { it.dayOfMonth } + + if (sessionDates.isEmpty()) { + val pageable = PageRequest.of(page, size) + + val emptyPage: Page = + PageImpl(emptyList(), pageable, 0) + + return MonthlyAttendancePageResponse( + year = year, + month = month, + sessionDates = emptyList(), + members = PageResponse.from(emptyPage) + ) + } + + val pageable = PageRequest.of(page, size) + val memberPage = memberProfileRepository.findAllByOrderByPartAscNameAsc(pageable) + val pageMemberIds = memberPage.content.mapNotNull { it.memberId } + if (pageMemberIds.isEmpty()) { + throw CustomException(MemberErrorCode.PAGE_MEMBERS_NOT_FOUND) + } + + val attendanceList = attendanceRepository.findByMemberIdInAndAttendanceTimeBetween( + pageMemberIds, + start, + end + ) + val attendanceIdByMemberDate: Map, Long> = + attendanceList.associateBy( + { attendance -> + val sessionDate: LocalDate = + sessionDateById[attendance.sessionId] ?: attendance.attendanceTime.toLocalDate() + attendance.memberId to sessionDate + }, + { it.id!! } + ) + + data class Row( + val memberId: Long, + val date: LocalDate, + val attendanceId: Long?, + val status: AttendancePointType, + val point: Int, + ) + + val rows: List = attendanceList.map { attendance -> + val date: LocalDate = + sessionDateById[attendance.sessionId] ?: attendance.attendanceTime.toLocalDate() + + Row( + memberId = attendance.memberId, + date = date, + attendanceId = attendance.id!!, + status = attendance.status, + point = attendance.status.points + ) + } + + val rowsByMember: Map> = rows.groupBy { it.memberId } + + val memberDtos: List = memberPage.content.map { profile -> + val memberId = profile.memberId!! + + val baseRecords: MutableList = + rowsByMember[memberId] + ?.sortedBy { it.date } + ?.map { row -> + AttendanceRecordDto( + date = row.date, + attendanceId = row.attendanceId, + status = row.status, + point = row.point + ) + } + ?.toMutableList() + ?: mutableListOf() + + if (sessionDates.isNotEmpty()) { + val recordedDates: Set = baseRecords.map { it.date }.toSet() + + sessionDates + .filter { it !in recordedDates } + .forEach { date -> + baseRecords.add( + AttendanceRecordDto( + date = date, + attendanceId = attendanceIdByMemberDate[memberId to date], + status = null, + point = null + ) + ) + } + baseRecords.sortBy { it.date } + } + + MemberMonthlyAttendanceDto( + memberId = memberId, + name = profile.name ?: "Unknown", + records = baseRecords + ) + } + + val dtoPage = memberPage.map { profile -> + memberDtos.first { it.memberId == profile.memberId } + } + + return MonthlyAttendancePageResponse( + year = year, + month = month, + sessionDates = sessionDays, + members = PageResponse.from(dtoPage) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt new file mode 100644 index 0000000..694e8ce --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt @@ -0,0 +1,75 @@ +package onku.backend.domain.point.service + +import onku.backend.domain.absence.AbsenceReport +import onku.backend.domain.attendance.util.AbsenceReportToAttendancePointMapper +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.point.MemberPointHistory +import onku.backend.domain.point.converter.MemberPointConverter +import onku.backend.domain.point.dto.MemberPointHistoryResponse +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.global.exception.CustomException +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class MemberPointHistoryService( + private val recordRepository: MemberPointHistoryRepository, + private val memberProfileRepository: MemberProfileRepository +) { + + @Transactional(readOnly = true) + fun getHistory(member: Member, safePage: Int, size: Int): MemberPointHistoryResponse { + val pageable = PageRequest.of(safePage, size) + + val profile = memberProfileRepository.findById(member.id!!) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + val name = profile.name + + // 누적 합계 + val sums = recordRepository.sumPointsForMember(member) + val plusPoints = sums.getPlusPoints().toInt() + val minusPoints = sums.getMinusPoints().toInt() + val totalPoints = sums.getTotalPoints().toInt() + + // 페이지 목록 (최신순) + val page = recordRepository.findByMemberOrderByOccurredAtDesc(member, pageable) + val records = page.content.map(MemberPointConverter::toResponse) + + return MemberPointHistoryResponse( + memberId = member.id!!, + name = name, + plusPoints = plusPoints, + minusPoints = minusPoints, + totalPoints = totalPoints, + records = records, + totalPages = page.totalPages, + isLastPage = page.isLast + ) + } + + @Transactional + fun upsertPointFromAbsenceReport(absenceReport: AbsenceReport) { + var memberPointHistory = recordRepository.findByWeekAndMember(absenceReport.session.week, absenceReport.member) + if(memberPointHistory == null) { + memberPointHistory = MemberPointHistory.ofAttendance( + member = absenceReport.member, + status = AbsenceReportToAttendancePointMapper.map(absenceReport.approval, absenceReport.approvedType), + occurredAt = LocalDateTime.now(), + week = absenceReport.session.week + ) + } + else { + memberPointHistory.updateAttendancePointType( + status = AbsenceReportToAttendancePointMapper.map(absenceReport.approval, absenceReport.approvedType), + occurredAt = LocalDateTime.now() + ) + } + recordRepository.save( + memberPointHistory + ) + } +} diff --git a/src/main/kotlin/onku/backend/domain/session/Session.kt b/src/main/kotlin/onku/backend/domain/session/Session.kt new file mode 100644 index 0000000..dcffe60 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/Session.kt @@ -0,0 +1,52 @@ +package onku.backend.domain.session + +import jakarta.persistence.* +import onku.backend.domain.session.dto.request.SessionSaveRequest +import onku.backend.domain.session.enums.SessionCategory +import onku.backend.global.entity.BaseEntity +import java.time.LocalDate +import java.time.LocalDateTime + +@Entity +@Table(name = "session") +class Session( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "session_id") + val id: Long? = null, + + @OneToOne( + fetch = FetchType.LAZY, + cascade = [CascadeType.PERSIST, CascadeType.MERGE], + optional = true, + orphanRemoval = true + ) + @JoinColumn(name = "session_detail_id") + var sessionDetail: SessionDetail? = null, + + @Column(name = "title", nullable = false, length = 255) + var title: String, + + @Column(name = "start_date", nullable = false) + var startDate: LocalDate, + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 32) + var category: SessionCategory, + + @Column(name = "week", nullable = false, unique = true) + var week: Long, + + var attendanceFinalized: Boolean = false, + var attendanceFinalizedAt: LocalDateTime? = null, + + @Column(name = "is_holiday") + var isHoliday : Boolean = false + ) : BaseEntity(){ + fun update(sessionSaveRequest: SessionSaveRequest) { + this.title = sessionSaveRequest.title + this.week = sessionSaveRequest.week + this.startDate = sessionSaveRequest.sessionDate + this.category = sessionSaveRequest.category + this.isHoliday = sessionSaveRequest.isHoliday + } + } diff --git a/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt b/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt new file mode 100644 index 0000000..26d2de4 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt @@ -0,0 +1,27 @@ +package onku.backend.domain.session + +import jakarta.persistence.* +import onku.backend.global.entity.BaseEntity +import java.time.LocalTime + +@Entity +@Table(name = "session_detail") +class SessionDetail( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "session_detail_id") + val id: Long? = null, + + @Column(name = "place", nullable = false) + var place: String, + + @Column(name = "start_time", nullable = false) + var startTime: LocalTime, + + @Column(name = "end_time", nullable = false) + var endTime: LocalTime, + + @Lob + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + var content: String, +) : BaseEntity() { +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt b/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt new file mode 100644 index 0000000..2f31dfc --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt @@ -0,0 +1,17 @@ +package onku.backend.domain.session + +import onku.backend.global.exception.ApiErrorCode +import org.springframework.http.HttpStatus + +enum class SessionErrorCode( + override val errorCode: String, + override val message: String, + override val status: HttpStatus +) : ApiErrorCode { + SESSION_DETAIL_NOT_FOUND("SESSION_DETAIL404", "세션 상세정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + SESSION_IMAGE_NOT_FOUND("SESSION_IMAGE404", "세션 이미지를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + SESSION_NOT_FOUND("session001", "해당 세션이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + SESSION_PAST("session002", "이미 지난 세션입니다.", HttpStatus.BAD_REQUEST), + SESSION_IMMINENT("session003", "불참사유서 제출은 목요일까지입니다.", HttpStatus.BAD_REQUEST), + INVALID_SESSION("session004", "유효한 세션이 아닙니다.", HttpStatus.BAD_REQUEST), +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/SessionImage.kt b/src/main/kotlin/onku/backend/domain/session/SessionImage.kt new file mode 100644 index 0000000..342ed58 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/SessionImage.kt @@ -0,0 +1,21 @@ +package onku.backend.domain.session + +import jakarta.persistence.* +import onku.backend.global.entity.BaseEntity + +@Entity +@Table(name = "session_image") +class SessionImage( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "session_image_id") + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_detail_id") + val sessionDetail: SessionDetail, + + @Column(name = "url") + val url : String +) : BaseEntity() { + +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/annotation/SessionValidTimeRange.kt b/src/main/kotlin/onku/backend/domain/session/annotation/SessionValidTimeRange.kt new file mode 100644 index 0000000..4306661 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/annotation/SessionValidTimeRange.kt @@ -0,0 +1,15 @@ +package onku.backend.domain.session.annotation + +import jakarta.validation.Constraint +import jakarta.validation.Payload +import onku.backend.domain.session.validator.SessionTimeValidator +import kotlin.reflect.KClass + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [SessionTimeValidator::class]) +annotation class SessionValidTimeRange( + val message: String = "Start time cannot be after end time.", + val groups: Array> = [], + val payload: Array> = [] +) diff --git a/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt b/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt new file mode 100644 index 0000000..45a5ba5 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt @@ -0,0 +1,109 @@ +package onku.backend.domain.session.controller.staff + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import onku.backend.domain.member.Member +import onku.backend.domain.session.dto.request.DeleteSessionImageRequest +import onku.backend.domain.session.dto.request.SessionSaveRequest +import onku.backend.domain.session.dto.request.UploadSessionImageRequest +import onku.backend.domain.session.dto.request.UpsertSessionDetailRequest +import onku.backend.domain.session.dto.response.GetDetailSessionResponse +import onku.backend.domain.session.dto.response.GetInitialSessionResponse +import onku.backend.domain.session.dto.response.UploadSessionImageResponse +import onku.backend.domain.session.dto.response.UpsertSessionDetailResponse +import onku.backend.domain.session.facade.SessionFacade +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.page.PageResponse +import onku.backend.global.response.SuccessResponse +import onku.backend.global.s3.dto.GetPreSignedUrlDto +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/session/staff") +@Tag(name = "[STAFF] 세션 API", description = "세션 관련 API") +class SessionStaffController( + private val sessionFacade: SessionFacade +) { + @PostMapping("") + @Operation( + summary = "세션 일정 저장", + description = "세션 일정들을 저장합니다." + ) + fun sessionSave( + @RequestBody @Valid sessionSaveRequestList : List + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.sessionSave(sessionSaveRequestList))) + } + + @GetMapping("") + @Operation( + summary = "초기에 저장한 세션정보 불러오기", + description = "초기에 저장한 세션정보들을 불러옵니다." + ) + fun getInitialSession( + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int + ) : ResponseEntity>> { + val safePage = if (page < 1) 0 else page - 1 + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.getInitialSession(safePage, size))) + } + + @PostMapping("/detail") + @Operation( + summary = "세션 상세정보 upsert", + description = "세션 상세정보를 새로 입력하거나 수정합니다." + ) + fun upsertSessionDetail( + @RequestBody @Valid upsertSessionDetailRequest : UpsertSessionDetailRequest + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.upsertSessionDetail(upsertSessionDetailRequest))) + } + + @PostMapping("/detail/image") + @Operation( + summary = "세션 상세정보 이미지 업로드", + description = "세션의 이미지를 업로드 합니다." + ) + fun uploadSessionImage( + @CurrentMember member: Member, + @RequestBody uploadSessionImageRequest : UploadSessionImageRequest + ) : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.uploadSessionImage(member, uploadSessionImageRequest))) + } + + @DeleteMapping("/detail/image") + @Operation( + summary = "세션 상세정보 이미지 삭제", + description = "세션 상세정보 이미지 삭제를 합니다 (삭제용 presignedUrl 발급)" + ) + fun deleteSessionImage( + @RequestBody deleteSessionImageRequest : DeleteSessionImageRequest + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.deleteSessionImage(deleteSessionImageRequest))) + } + + @GetMapping("/detail/{id}") + @Operation( + summary = "세션 상세페이지 조회", + description = "세션 상세정보를 조회합니다." + ) + fun getSessionDetailPage( + @PathVariable(name = "id") detailId : Long + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.getSessionDetailPage(detailId))) + } + + @PatchMapping("/{id}") + @Operation( + summary = "세션 수정", + description = "세션 정보를 수정합니다. (제목, 주차, 일자, 종류, 공휴일 세션 여부)" + ) + fun patchSession( + @PathVariable id : Long, + @RequestBody @Valid sessionSaveRequest: SessionSaveRequest + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.patchSession(id, sessionSaveRequest))) + } +} diff --git a/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt new file mode 100644 index 0000000..9fd85bd --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt @@ -0,0 +1,55 @@ +package onku.backend.domain.session.controller.user + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.session.dto.response.* +import onku.backend.domain.session.facade.SessionFacade +import onku.backend.global.response.SuccessResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/session") +@Tag(name = "세션 API", description = "세션 관련 API") +class SessionController( + private val sessionFacade: SessionFacade +) { + @GetMapping("/absence") + @Operation( + summary = "불참사유서 제출 페이지에서 세션 정보 조회", + description = "불참사유서 제출 페이지에서 세션 정보를 조회합니다." + ) + fun showSessionAboutAbsence( + ) : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showSessionAboutAbsence())) + } + + @GetMapping("/this-week") + @Operation( + summary = "금주 세션 정보 조회", + description = "금주 세션 정보를 조회합니다." + ) + fun showThisWeekSessionInfo() : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showThisWeekSessionInfo())) + } + + @GetMapping("") + @Operation( + summary = "전체 세션 정보 조회", + description = "전체 세션 정보를 시간순으로 조회합니다." + ) + fun showAllSessionCards() : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showAllSessionCards())) + } + + @GetMapping("/notice/{sessionId}") + @Operation( + summary = "세션 공지 조회", + description = "세션 Id에 맞는 세션 공지를 조회합니다." + ) + fun getSessionNotice( + @PathVariable(name = "sessionId") sessionId : Long + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.getSessionNotice(sessionId))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/dto/SessionImageDto.kt b/src/main/kotlin/onku/backend/domain/session/dto/SessionImageDto.kt new file mode 100644 index 0000000..6cfe8f6 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/SessionImageDto.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.session.dto + +import io.swagger.v3.oas.annotations.media.Schema + +data class SessionImageDto ( + @Schema(description = "세션 이미지 ID", example = "1") + val sessionImageId : Long, + @Schema(description = "세션 이미지 PreSignedUrl", example = "https://~~") + val sessionImagePreSignedUrl : String, + @Schema(description = "세션 원본 이미지 이름", example = "example.png") + val sessionOriginalFileName : String +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/dto/request/DeleteSessionImageRequest.kt b/src/main/kotlin/onku/backend/domain/session/dto/request/DeleteSessionImageRequest.kt new file mode 100644 index 0000000..8507a5a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/request/DeleteSessionImageRequest.kt @@ -0,0 +1,5 @@ +package onku.backend.domain.session.dto.request + +data class DeleteSessionImageRequest( + val sessionImageId : Long +) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/request/SessionImageRequest.kt b/src/main/kotlin/onku/backend/domain/session/dto/request/SessionImageRequest.kt new file mode 100644 index 0000000..420f272 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/request/SessionImageRequest.kt @@ -0,0 +1,5 @@ +package onku.backend.domain.session.dto.request + +data class SessionImageRequest( + val fileName : String +) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/request/SessionSaveRequest.kt b/src/main/kotlin/onku/backend/domain/session/dto/request/SessionSaveRequest.kt new file mode 100644 index 0000000..be697a6 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/request/SessionSaveRequest.kt @@ -0,0 +1,14 @@ +package onku.backend.domain.session.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import onku.backend.domain.session.enums.SessionCategory +import java.time.LocalDate + +data class SessionSaveRequest ( + @field:NotNull val week : Long, + @field:NotNull val sessionDate : LocalDate, + @field:NotBlank val title : String, + @field:NotNull val category: SessionCategory, + @field:NotNull val isHoliday: Boolean, + ) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/dto/request/UploadSessionImageRequest.kt b/src/main/kotlin/onku/backend/domain/session/dto/request/UploadSessionImageRequest.kt new file mode 100644 index 0000000..6dae794 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/request/UploadSessionImageRequest.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.session.dto.request + +import jakarta.validation.constraints.NotNull + +data class UploadSessionImageRequest ( + @field:NotNull val sessionDetailId : Long, + val imageFileName : List +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/dto/request/UpsertSessionDetailRequest.kt b/src/main/kotlin/onku/backend/domain/session/dto/request/UpsertSessionDetailRequest.kt new file mode 100644 index 0000000..61ccc25 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/request/UpsertSessionDetailRequest.kt @@ -0,0 +1,23 @@ +package onku.backend.domain.session.dto.request + +import com.fasterxml.jackson.annotation.JsonFormat +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import onku.backend.domain.session.annotation.SessionValidTimeRange +import java.time.LocalTime +@SessionValidTimeRange +data class UpsertSessionDetailRequest( + val sessionDetailId : Long?, + @field:NotNull val sessionId : Long, + @field:NotBlank val place : String, + @field:NotNull + @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm:ss") + @Schema(description = "시작시각", example = "14:00:00") + val startTime : LocalTime, + @field:NotNull + @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm:ss") + @Schema(description = "종료시각", example = "14:00:00") + val endTime : LocalTime, + @field:NotBlank val content: String, +) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/response/GetDetailSessionResponse.kt b/src/main/kotlin/onku/backend/domain/session/dto/response/GetDetailSessionResponse.kt new file mode 100644 index 0000000..428da87 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/GetDetailSessionResponse.kt @@ -0,0 +1,27 @@ +package onku.backend.domain.session.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.session.dto.SessionImageDto +import java.time.LocalDateTime +import java.time.LocalTime + +data class GetDetailSessionResponse( + @Schema(description = "세션 정보 ID", example = "1") + val sessionId : Long, + @Schema(description = "세션 상세 정보 ID", example = "1") + val sessionDetailId : Long, + @Schema(description = "세션 장소", example = "마루180") + val place : String?, + @Schema(description = "세션 시작 시각", example = "09:00:00") + val startTime : LocalTime?, + @Schema(description = "세션 종료 시각", example = "15:00:00") + val endTime : LocalTime?, + @Schema(description = "본문", example = "어쩌구 저쩌구") + val content : String?, + @Schema(description = "세션 이미지", example = "리스트형식") + val sessionImages : List, + @Schema(description = "생성일자(처음 만들어진 시각)", example = "2025-07-15T14:00:00") + val createdAt : LocalDateTime, + @Schema(description = "업데이트 날짜(마지막으로 수정된 시각)", example = "2025-07-16T14:00:00") + val updatedAt : LocalDateTime +) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/response/GetInitialSessionResponse.kt b/src/main/kotlin/onku/backend/domain/session/dto/response/GetInitialSessionResponse.kt new file mode 100644 index 0000000..3630d96 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/GetInitialSessionResponse.kt @@ -0,0 +1,20 @@ +package onku.backend.domain.session.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.session.enums.SessionCategory +import java.time.LocalDate + +data class GetInitialSessionResponse( + @Schema(description = "세션 ID", example = "1") + val sessionId : Long, + @Schema(description = "세션 시작 일자", example = "2025-10-21") + val startDate : LocalDate, + @Schema(description = "세션 제목", example = "전문가 초청 강의") + val title : String, + @Schema(description = "세션 종류", example = "밋업프로젝트") + val category : SessionCategory, + @Schema(description = "세션 상세정보", example = "만약 null이면 세션정보 입력여부 false로 하면 됨") + val sessionDetailId : Long?, + @Schema(description = "공휴일 세션 여부", example = "true") + val isHoliday : Boolean +) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/response/GetSessionNoticeResponse.kt b/src/main/kotlin/onku/backend/domain/session/dto/response/GetSessionNoticeResponse.kt new file mode 100644 index 0000000..5909cf0 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/GetSessionNoticeResponse.kt @@ -0,0 +1,34 @@ +package onku.backend.domain.session.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.session.dto.SessionImageDto +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +data class GetSessionNoticeResponse( + @Schema(description = "세션 관련 정보", example = "1") + val sessionId : Long, + @Schema(description = "세션 상세 ID", example = "1") + val sessionDetailId : Long, + @Schema(description = "세션 제목", example = "아이디어 발표 & 커피챗 세션") + val title : String?, + @Schema(description = "세션 장소", example = "마루 180") + val place : String?, + @Schema(description = "시작 일자", example = "2025-11-01") + val startDate : LocalDate?, + @Schema(description = "시작 시각", example = "13:00") + val startTime : LocalTime?, + @Schema(description = "종료 시각", example = "17:00") + val endTime : LocalTime?, + @Schema(description = "공지내용 데이터", example = "세션합니당") + val content : String?, + @Schema(description = "세션 관련 이미지", example = "객체임") + val images : List, + @Schema(description = "공휴일 세션 여부", example = "true") + val isHoliday : Boolean?, + @Schema(description = "세션 초기작성 시간(처음 생성)", example = "2025-07-15T14:00:00") + val createdAt : LocalDateTime, + @Schema(description = "세션 마지막 업데이트 시간", example = "2025-07-15T14:00:00") + val updatedAt : LocalDateTime +) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/response/SessionAboutAbsenceResponse.kt b/src/main/kotlin/onku/backend/domain/session/dto/response/SessionAboutAbsenceResponse.kt new file mode 100644 index 0000000..ec6a1a0 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/SessionAboutAbsenceResponse.kt @@ -0,0 +1,17 @@ +package onku.backend.domain.session.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class SessionAboutAbsenceResponse( + @Schema(description = "세션 ID", example = "1") + val sessionId : Long?, + @Schema(description = "세션 제목", example = "전문가 초청 강연") + val title : String, + @Schema(description = "세션 주차", example = "1") + val week : Long, + @Schema(description = "세션 시작 일자", example = "2025-10-24") + val startDate : LocalDate, + @Schema(description = "활성화 여부", example = "true") + val active : Boolean +) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/response/SessionCardInfo.kt b/src/main/kotlin/onku/backend/domain/session/dto/response/SessionCardInfo.kt new file mode 100644 index 0000000..90e5646 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/SessionCardInfo.kt @@ -0,0 +1,18 @@ +package onku.backend.domain.session.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.session.enums.SessionCategory +import java.time.LocalDate + +data class SessionCardInfo( + @Schema(description = "세션 ID", example = "1") + val sessionId : Long, + @Schema(description = "세션 카테고리", example = "밋업 프로젝트") + val sessionCategory: SessionCategory, + @Schema(description = "세션 제목", example = "아이디어 발제 및 커피챗") + val title : String?, + @Schema(description = "세션 시작 일자", example = "2025-09-27") + val startDate : LocalDate?, + @Schema(description = "공휴일 세션 여부", example = "true") + val isHoliday : Boolean?, +) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/response/SessionTimeResetResponse.kt b/src/main/kotlin/onku/backend/domain/session/dto/response/SessionTimeResetResponse.kt new file mode 100644 index 0000000..e05725c --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/SessionTimeResetResponse.kt @@ -0,0 +1,24 @@ +package onku.backend.domain.session.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime +import java.time.LocalTime + +@Schema(description = "세션 시간 수정 결과") +data class SessionTimeResetResponse( + + @Schema(description = "세션 ID", example = "1") + val sessionId: Long, + + @Schema(description = "수정된 세션 시작 시간", example = "19:20:00") + val startTime: LocalTime, + + @Schema(description = "수정된 세션 종료 시간", example = "21:20:00") + val endTime: LocalTime, + + @Schema(description = "출석 확정 여부", example = "false") + val attendanceFinalized: Boolean, + + @Schema(description = "출석 확정 시각 (null로 초기화)", example = "null") + val attendanceFinalizedAt: LocalDateTime? +) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/response/ThisWeekSessionInfo.kt b/src/main/kotlin/onku/backend/domain/session/dto/response/ThisWeekSessionInfo.kt new file mode 100644 index 0000000..e51777e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/ThisWeekSessionInfo.kt @@ -0,0 +1,24 @@ +package onku.backend.domain.session.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate +import java.time.LocalTime + +data class ThisWeekSessionInfo( + @Schema(description = "세션 ID", example = "1") + val sessionId : Long, + @Schema(description = "세션 상세 정보 ID", example = "1") + val sessionDetailId : Long?, + @Schema(description = "세션 제목", example = "아이디어 발제 및 커피챗") + val title : String?, + @Schema(description = "세션 장소", example = "마루 180") + val place : String?, + @Schema(description = "세션 시작 일자", example = "2025-09-22") + val startDate : LocalDate?, + @Schema(description = "시작 시각", example = "13:00") + val startTime : LocalTime?, + @Schema(description = "종료 시각", example = "16:00") + val endTime : LocalTime?, + @Schema(description = "공휴일 세션 여부", example = "true") + val isHoliday : Boolean?, +) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/response/UploadSessionImageResponse.kt b/src/main/kotlin/onku/backend/domain/session/dto/response/UploadSessionImageResponse.kt new file mode 100644 index 0000000..c199a2e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/UploadSessionImageResponse.kt @@ -0,0 +1,6 @@ +package onku.backend.domain.session.dto.response + +data class UploadSessionImageResponse ( + val sessionImageId : Long, + val sessionImagePreSignedUrl : String +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/dto/response/UpsertSessionDetailResponse.kt b/src/main/kotlin/onku/backend/domain/session/dto/response/UpsertSessionDetailResponse.kt new file mode 100644 index 0000000..c79f7c3 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/UpsertSessionDetailResponse.kt @@ -0,0 +1,5 @@ +package onku.backend.domain.session.dto.response + +data class UpsertSessionDetailResponse( + val sessionDetailId : Long +) diff --git a/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt new file mode 100644 index 0000000..17b2e5a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt @@ -0,0 +1,14 @@ +package onku.backend.domain.session.enums + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = "세션 종류", + example = "CORPORATE_PROJECT" +) +enum class SessionCategory { + @Schema(description = "기업 프로젝트") CORPORATE_PROJECT, + @Schema(description = "밋업 프로젝트") MEETUP_PROJECT, + @Schema(description = "네트워킹") NETWORKING, + @Schema(description = "휴회 세션") REST, +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt new file mode 100644 index 0000000..059fdae --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -0,0 +1,205 @@ +package onku.backend.domain.session.facade + +import onku.backend.domain.member.Member +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.dto.SessionImageDto +import onku.backend.domain.session.dto.request.DeleteSessionImageRequest +import onku.backend.domain.session.dto.request.SessionSaveRequest +import onku.backend.domain.session.dto.request.UploadSessionImageRequest +import onku.backend.domain.session.dto.request.UpsertSessionDetailRequest +import onku.backend.domain.session.dto.response.* +import onku.backend.domain.session.service.SessionDetailService +import onku.backend.domain.session.service.SessionImageService +import onku.backend.domain.session.service.SessionNoticeService +import onku.backend.domain.session.service.SessionService +import onku.backend.global.exception.CustomException +import onku.backend.global.page.PageResponse +import onku.backend.global.s3.dto.GetPreSignedUrlDto +import onku.backend.global.s3.enums.FolderName +import onku.backend.global.s3.enums.UploadOption +import onku.backend.global.s3.service.S3Service +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class SessionFacade( + private val sessionService: SessionService, + private val sessionDetailService: SessionDetailService, + private val sessionImageService : SessionImageService, + private val s3Service: S3Service, + private val sessionNoticeService: SessionNoticeService, + private val clock: Clock = Clock.system(ZoneId.of("Asia/Seoul")), +) { + fun showSessionAboutAbsence(): List { + return sessionService.getUpcomingSessionsForAbsence() + } + + fun sessionSave(sessionSaveRequestList: List): Boolean { + return sessionService.saveAll(sessionSaveRequestList) + } + + fun getInitialSession(page: Int, size: Int): PageResponse { + val pageRequest = PageRequest.of(page, size) + val initialSessionPage = sessionService.getInitialSession(pageRequest) + return PageResponse.from(initialSessionPage) + } + + fun upsertSessionDetail(upsertSessionDetailRequest: UpsertSessionDetailRequest): UpsertSessionDetailResponse { + val session = sessionService.getById(upsertSessionDetailRequest.sessionId) + return UpsertSessionDetailResponse( + sessionDetailService.upsertSessionDetail( + session, + upsertSessionDetailRequest + ) + ) + } + + fun uploadSessionImage( + member: Member, + uploadSessionImageRequest: UploadSessionImageRequest + ): List { + val sessionDetail = sessionDetailService.getById(uploadSessionImageRequest.sessionDetailId) + + val preSignedList = uploadSessionImageRequest.imageFileName.map { image -> + val preSign = s3Service.getPostS3Url( + member.id!!, + image.fileName, + FolderName.SESSION.name, + UploadOption.IMAGE + ) + preSign + } + val savedImages = sessionImageService.uploadImages( + sessionDetail = sessionDetail, + preSignedImages = preSignedList + ) + + return savedImages.map { entity -> + val preSign = preSignedList.first { it.key == entity.url } + UploadSessionImageResponse( + sessionImageId = entity.id!!, + sessionImagePreSignedUrl = preSign.preSignedUrl + ) + } + } + + fun deleteSessionImage(deleteSessionImageRequest: DeleteSessionImageRequest): GetPreSignedUrlDto { + val sessionImage = sessionImageService.getById(deleteSessionImageRequest.sessionImageId) + val getS3UrlDto = s3Service.getDeleteS3Url(sessionImage.url) + sessionImageService.deleteImage(sessionImage.id!!) + return GetPreSignedUrlDto( + getS3UrlDto.preSignedUrl + ) + } + + fun getSessionDetailPage(detailId: Long): GetDetailSessionResponse { + val images = sessionImageService.findAllBySessionDetailId(detailId) + val session = sessionService.getByDetailIdFetchDetail(detailId) + val detail = session.sessionDetail!! + val imageDtos = images.map { image -> + val preSignedDto = s3Service.getGetS3Url( + memberId = 0, + key = image.url + ) + + SessionImageDto( + sessionImageId = image.id!!, + sessionImagePreSignedUrl = preSignedDto.preSignedUrl, + sessionOriginalFileName = preSignedDto.originalName + ) + } + + return GetDetailSessionResponse( + sessionId = session.id!!, + sessionDetailId = detail.id!!, + place = detail.place, + startTime = detail.startTime, + endTime = detail.endTime, + content = detail.content, + sessionImages = imageDtos, + createdAt = detail.createdAt, + updatedAt = detail.updatedAt + ) + } + + fun showThisWeekSessionInfo(): List { + return sessionService.getThisWeekSession() + } + + fun showAllSessionCards(): List { + return sessionService.getAllSessionsOrderByStartDate() + } + + fun getSessionNotice(sessionId: Long): GetSessionNoticeResponse { + val (session, detail, images) = sessionNoticeService.getSessionWithImages(sessionId) + + // Presigned URL 생성 + val imageDtos = images.map { img -> + val preSignDto = s3Service.getGetS3Url(0L, img.url) + SessionImageDto( + sessionImageId = img.id!!, + sessionImagePreSignedUrl = preSignDto.preSignedUrl, + sessionOriginalFileName = preSignDto.originalName + ) + } + + return GetSessionNoticeResponse( + sessionId = session.id!!, + sessionDetailId = detail.id!!, + title = session.title, + place = detail.place, + startDate = session.startDate, + startTime = detail.startTime, + endTime = detail.endTime, + content = detail.content, + images = imageDtos, + isHoliday = session.isHoliday, + createdAt = session.createdAt, + updatedAt = session.updatedAt + ) + } + fun deleteSession(sessionId: Long) { + sessionService.deleteCascade(sessionId) + } + + @Transactional + fun patchSession(id: Long, sessionSaveRequest: SessionSaveRequest): Boolean { + val session = sessionService.getById(id) + if(session.startDate.isBefore(LocalDate.now())) { + throw CustomException(SessionErrorCode.SESSION_PAST) + } + session.update(sessionSaveRequest) + return true + } + + @Transactional + fun resetSessionTime(sessionId: Long): SessionTimeResetResponse { + val session = sessionService.getById(sessionId) + + session.attendanceFinalized = false + session.attendanceFinalizedAt = null + + val detail = session.sessionDetail + ?: throw CustomException(SessionErrorCode.SESSION_DETAIL_NOT_FOUND) + + val baseDateTime = LocalDateTime.now(clock).plusMinutes(20) + val newStartTime = baseDateTime.toLocalTime() + val newEndTime = baseDateTime.plusHours(2).toLocalTime() + + detail.startTime = newStartTime + detail.endTime = newEndTime + + return SessionTimeResetResponse( + sessionId = session.id!!, + startTime = newStartTime, + endTime = newEndTime, + attendanceFinalized = session.attendanceFinalized, + attendanceFinalizedAt = session.attendanceFinalizedAt + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionDetailRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionDetailRepository.kt new file mode 100644 index 0000000..35c1b0f --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionDetailRepository.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.session.repository + +import onku.backend.domain.session.SessionDetail +import org.springframework.data.repository.CrudRepository + +interface SessionDetailRepository : CrudRepository { + +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt new file mode 100644 index 0000000..618381a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt @@ -0,0 +1,33 @@ +package onku.backend.domain.session.repository + +import onku.backend.domain.session.SessionImage +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.query.Param + +interface SessionImageRepository : CrudRepository { + fun findAllBySessionDetailId(sessionDetailId: Long): List + + @Query(""" + SELECT si + FROM SessionImage si + WHERE si.sessionDetail.id = :detailId + ORDER BY si.id ASC + """) + fun findByDetailId(@Param("detailId") detailId: Long): List + + @Query(""" + select si.url + from SessionImage si + where si.sessionDetail.id = :detailId + """) + fun findAllImageKeysByDetailId(@Param("detailId") detailId: Long): List + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + delete from SessionImage si + where si.sessionDetail.id = :detailId + """) + fun deleteByDetailIdBulk(@Param("detailId") detailId: Long): Int +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt new file mode 100644 index 0000000..2de5ede --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -0,0 +1,152 @@ +package onku.backend.domain.session.repository + +import onku.backend.domain.session.Session +import onku.backend.domain.session.dto.response.ThisWeekSessionInfo +import onku.backend.domain.session.enums.SessionCategory +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.query.Param +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +interface SessionRepository : CrudRepository { + + @Query(""" + SELECT s + FROM Session s + JOIN s.sessionDetail sd + WHERE function('timestamp', s.startDate, sd.startTime) <= :startBound + AND function('timestamp', s.startDate, sd.endTime) >= :endBound + ORDER BY s.startDate DESC, sd.startTime DESC + """) + fun findOpenWindow( + @Param("startBound") startBound: LocalDateTime, + @Param("endBound") endBound: LocalDateTime + ): List + + @Query( + """ + SELECT s + FROM Session s + WHERE s.startDate >= :now AND s.category <> :restCategory + ORDER BY s.startDate ASC + """ + ) + fun findUpcomingSessions( + @Param("now") now: LocalDate, + @Param("restCategory") restCategory: SessionCategory = SessionCategory.REST + ): List + + @Query( + """ + SELECT s + FROM Session s + ORDER BY s.startDate ASC + """ + ) + fun findAllSessionsOrderByStartDate(): List + + + @Query(""" + SELECT s + FROM Session s + """) + fun findAll(pageable: Pageable): Page + + interface StartParts { + fun getStartDate(): LocalDate + fun getStartTime(): LocalTime + } + + @Query( + """ + select s.startDate as startDate, d.startTime as startTime + from Session s + join s.sessionDetail d + where s.startDate between :startDate and :endDate + """ + ) + fun findStartDateAndTimeBetweenDates( + @Param("startDate") startDate: LocalDate, + @Param("endDate") endDate: LocalDate + ): List + + @Query(""" + SELECT s + FROM Session s + JOIN s.sessionDetail sd + WHERE s.attendanceFinalized = false + AND (s.startDate < :pivotDate or (s.startDate = :pivotDate and sd.startTime <= :pivotTime)) + """) + fun findFinalizeDue( + @Param("pivotDate") pivotDate: LocalDate, + @Param("pivotTime") pivotTime: LocalTime + ): List + + @Query(""" + SELECT s + FROM Session s + JOIN s.sessionDetail sd + WHERE s.attendanceFinalized = false + AND (s.startDate > :pivotDate or (s.startDate = :pivotDate and sd.startTime > :pivotTime)) + """) + fun findUnfinalizedAfter( + @Param("pivotDate") pivotDate: LocalDate, + @Param("pivotTime") pivotTime: LocalTime + ): List + + + @Query(""" + SELECT + s.id as sessionId, + sd.id as sessionDetailId, + s.title as title, + sd.place as place, + s.startDate as startDate, + sd.startTime as startTime, + sd.endTime as endTime, + s.isHoliday as isHoliday + FROM Session s + LEFT JOIN s.sessionDetail sd + WHERE s.startDate BETWEEN :start AND :end + ORDER BY s.startDate ASC + """) + fun findThisWeekSunToSat( + @Param("start") start: LocalDate, + @Param("end") end: LocalDate + ): List + + @Query(""" + SELECT s + FROM Session s + LEFT JOIN FETCH s.sessionDetail sd + WHERE s.id = :id + """) + fun findWithDetail(@Param("id") id: Long): Session? + + @Query("select s.sessionDetail.id from Session s where s.id = :sessionId") + fun findDetailIdBySessionId(@Param("sessionId") sessionId: Long): Long? + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Session s set s.sessionDetail = null where s.id = :sessionId") + fun detachDetailFromSession(@Param("sessionId") sessionId: Long): Int + + @Query( + """ + select s + from Session s + join fetch s.sessionDetail sd + where sd.id = :detailId + """ + ) + fun findByDetailIdFetchDetail(@Param("detailId") detailId: Long): Session? + + fun findByStartDateBetween( + startDate: LocalDate, + endDate: LocalDate + ): List +} diff --git a/src/main/kotlin/onku/backend/domain/session/repository/projection/ThisWeekSessionProjection.kt b/src/main/kotlin/onku/backend/domain/session/repository/projection/ThisWeekSessionProjection.kt new file mode 100644 index 0000000..691f84f --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/repository/projection/ThisWeekSessionProjection.kt @@ -0,0 +1,14 @@ +package onku.backend.domain.session.repository.projection + +import java.time.LocalDate +import java.time.LocalTime + +interface ThisWeekSessionProjection { + val sessionId: Long + val sessionDetailId: Long? + val title: String? + val place: String? + val startDate: LocalDate? + val startTime: LocalTime? + val endTime: LocalTime? +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt new file mode 100644 index 0000000..965c8b6 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt @@ -0,0 +1,63 @@ +package onku.backend.domain.session.service + +import onku.backend.domain.attendance.AttendancePolicy.ABSENT_START_MINUTES +import onku.backend.domain.attendance.finalize.FinalizeEvent +import onku.backend.domain.session.Session +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.dto.request.UpsertSessionDetailRequest +import onku.backend.domain.session.repository.SessionDetailRepository +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import onku.backend.domain.session.util.SessionTimeUtil.absentBoundary + +@Service +class SessionDetailService( + private val sessionDetailRepository: SessionDetailRepository, + private val sessionRepository: SessionRepository, + private val applicationEventPublisher: ApplicationEventPublisher +) { + @Transactional + fun upsertSessionDetail(session : Session, upsertSessionDetailRequest: UpsertSessionDetailRequest): Long { + val detail = if (upsertSessionDetailRequest.sessionDetailId != null) { + val d = sessionDetailRepository.findById(upsertSessionDetailRequest.sessionDetailId) + .orElseThrow { CustomException(SessionErrorCode.SESSION_DETAIL_NOT_FOUND) } + d.place = upsertSessionDetailRequest.place + d.startTime = upsertSessionDetailRequest.startTime + d.endTime = upsertSessionDetailRequest.endTime + d.content = upsertSessionDetailRequest.content + d + } else { + sessionDetailRepository.save( + SessionDetail( + place = upsertSessionDetailRequest.place, + startTime = upsertSessionDetailRequest.startTime, + endTime = upsertSessionDetailRequest.endTime, + content = upsertSessionDetailRequest.content + ) + ) + } + session.sessionDetail = detail + + sessionRepository.save(session) + + val runAt = absentBoundary(session, ABSENT_START_MINUTES) + val sessionId = session.id ?: throw CustomException(SessionErrorCode.SESSION_NOT_FOUND) + + applicationEventPublisher.publishEvent( + FinalizeEvent(sessionId, runAt) + ) + + return (detail.id ?: 0L) + } + + @Transactional(readOnly = true) + fun getById(id : Long) : SessionDetail { + return sessionDetailRepository.findById(id).orElseThrow{ + CustomException(SessionErrorCode.SESSION_DETAIL_NOT_FOUND) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt new file mode 100644 index 0000000..e0870ec --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt @@ -0,0 +1,44 @@ +package onku.backend.domain.session.service + +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.SessionImage +import onku.backend.domain.session.repository.SessionImageRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.s3.dto.GetS3UrlDto +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SessionImageService( + private val sessionImageRepository: SessionImageRepository, +) { + @Transactional + fun uploadImages( + sessionDetail: SessionDetail, + preSignedImages: List + ): List { + val entities = preSignedImages.map { info -> + SessionImage( + sessionDetail = sessionDetail, + url = info.key + ) + } + return sessionImageRepository.saveAll(entities).toList() + } + + @Transactional + fun deleteImage(id : Long) { + return sessionImageRepository.deleteById(id) + } + + @Transactional(readOnly = true) + fun getById(id : Long): SessionImage { + return sessionImageRepository.findById(id).orElseThrow{CustomException(SessionErrorCode.SESSION_IMAGE_NOT_FOUND)} + } + + @Transactional(readOnly = true) + fun findAllBySessionDetailId(detailId : Long) : List { + return sessionImageRepository.findAllBySessionDetailId(detailId) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionNoticeService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionNoticeService.kt new file mode 100644 index 0000000..ba4da68 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionNoticeService.kt @@ -0,0 +1,30 @@ +package onku.backend.domain.session.service + +import onku.backend.domain.session.Session +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.SessionImage +import onku.backend.domain.session.repository.SessionImageRepository +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SessionNoticeService( + private val sessionRepository: SessionRepository, + private val sessionImageRepository: SessionImageRepository, +) { + @Transactional(readOnly = true) + fun getSessionWithImages(sessionId: Long): Triple> { + val session = sessionRepository.findWithDetail(sessionId) + ?: throw CustomException(SessionErrorCode.SESSION_NOT_FOUND) + + val detail = session.sessionDetail + ?: throw CustomException(SessionErrorCode.SESSION_DETAIL_NOT_FOUND) + + val images = sessionImageRepository.findByDetailId(detail.id!!) + + return Triple(session, detail, images) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt new file mode 100644 index 0000000..a49c990 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -0,0 +1,156 @@ +package onku.backend.domain.session.service + +import onku.backend.domain.absence.repository.AbsenceReportRepository +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.session.validator.SessionValidator +import onku.backend.domain.session.Session +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.dto.response.SessionAboutAbsenceResponse +import onku.backend.domain.session.dto.request.SessionSaveRequest +import onku.backend.domain.session.dto.response.GetInitialSessionResponse +import onku.backend.domain.session.dto.response.SessionCardInfo +import onku.backend.domain.session.dto.response.ThisWeekSessionInfo +import onku.backend.domain.session.repository.SessionDetailRepository +import onku.backend.domain.session.repository.SessionImageRepository +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.s3.service.S3Service +import onku.backend.global.time.TimeRangeUtil +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDateTime +import java.time.ZoneId + +@Service +class SessionService( + private val sessionRepository: SessionRepository, + private val sessionValidator: SessionValidator, + private val sessionImageRepository: SessionImageRepository, + private val s3Service: S3Service, + private val clock: Clock = Clock.system(ZoneId.of("Asia/Seoul")), + private val attendanceRepository: AttendanceRepository, + private val absenceReportRepository: AbsenceReportRepository, + private val sessionDetailRepository: SessionDetailRepository, +) { + @Transactional(readOnly = true) + fun getUpcomingSessionsForAbsence(): List { + val now = LocalDateTime.now(clock) + + val sessions = sessionRepository.findUpcomingSessions(now.toLocalDate()) + + return sessions.map { s -> + val active = sessionValidator.isImminentSession(s, now) + SessionAboutAbsenceResponse( + sessionId = s.id, + title = s.title, + week = s.week, + startDate = s.startDate, + active = active + ) + } + } + + @Transactional(readOnly = true) + fun getById(id : Long) : Session { + return sessionRepository.findByIdOrNull(id) ?: throw CustomException(SessionErrorCode.SESSION_NOT_FOUND) + } + + @Transactional + fun saveAll(requests: List): Boolean { + val sessions = requests.map { r -> + Session( + title = r.title, + startDate = r.sessionDate, + category = r.category, + week = r.week, + sessionDetail = null, + isHoliday = r.isHoliday + ) + } + sessionRepository.saveAll(sessions) + return true + } + + @Transactional(readOnly = true) + fun getInitialSession(pageable: Pageable) : Page { + val initialSessions = sessionRepository.findAll(pageable) + return initialSessions.map { s -> + GetInitialSessionResponse( + sessionId = s.id!!, + startDate = s.startDate, + title = s.title, + category = s.category, + sessionDetailId = s.sessionDetail?.id, + isHoliday = s.isHoliday + ) + } + } + + @Transactional(readOnly = true) + fun getThisWeekSession(): List { + val range = TimeRangeUtil.thisWeekRange() + return sessionRepository.findThisWeekSunToSat(range.startOfWeek, range.endOfWeek).map { + ThisWeekSessionInfo( + sessionId = it.sessionId, + sessionDetailId = it.sessionDetailId, + title = it.title, + place = it.place, + startDate = it.startDate, + startTime = it.startTime, + endTime = it.endTime, + isHoliday = it.isHoliday, + ) + } + } + + fun getAllSessionsOrderByStartDate(): List { + return sessionRepository.findAllSessionsOrderByStartDate() + .map { session -> + SessionCardInfo( + sessionId = session.id!!, + sessionCategory = session.category, + title = session.title, + startDate = session.startDate, + isHoliday = session.isHoliday, + ) + } + } + + /** + * - 해당 sessionId의 데이터가 Session 테이블에 존재하는지 확인 + * - [삭제] Session id를 FK로 가지는 attendance, absenceReport 레코드 먼저 삭제 + * - sessionDetail 존재 여부 확인 및 존재 시 detailId 조회 + * - [삭제] FK를 고려하여 detailId에 해당하는 이미지(SessionImage) 먼저 삭제 (S3 + DB) + * - [삭제] detailId에 해당하는 sessionDetail 레코드 삭제 + * - [삭제] sessionId에 해당하는 Session 레코드 삭제 + */ + @Transactional + fun deleteCascade(sessionId: Long) { + sessionRepository.findWithDetail(sessionId) + ?: throw CustomException(SessionErrorCode.SESSION_NOT_FOUND) + + attendanceRepository.deleteAllBySessionId(sessionId) + absenceReportRepository.deleteAllBySessionId(sessionId) + + val detailId = sessionRepository.findDetailIdBySessionId(sessionId) + if (detailId != null) { + val keys = sessionImageRepository.findAllImageKeysByDetailId(detailId).filter { it.isNotBlank() } + if (keys.isNotEmpty()) s3Service.deleteObjectsNow(keys) + + sessionImageRepository.deleteByDetailIdBulk(detailId) + sessionRepository.detachDetailFromSession(sessionId) + sessionDetailRepository.deleteById(detailId) + } + sessionRepository.deleteById(sessionId) + } + + @Transactional(readOnly = true) + fun getByDetailIdFetchDetail(sessionDetailId : Long) : Session { + return sessionRepository.findByDetailIdFetchDetail(sessionDetailId) + ?: throw CustomException(SessionErrorCode.SESSION_NOT_FOUND) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/util/SessionTimeUtil.kt b/src/main/kotlin/onku/backend/domain/session/util/SessionTimeUtil.kt new file mode 100644 index 0000000..9b11a73 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/util/SessionTimeUtil.kt @@ -0,0 +1,28 @@ +package onku.backend.domain.session.util + +import onku.backend.domain.session.Session +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode +import java.time.LocalDateTime +import java.time.LocalTime + +object SessionTimeUtil { + + /** + * Session.startDate(LocalDate) + SessionDetail.startTime(LocalTime) => LocalDateTime + */ + @JvmStatic + fun startDateTime(session: Session): LocalDateTime { + val date = session.startDate + val time: LocalTime = session.sessionDetail?.startTime + ?: throw CustomException(ErrorCode.INVALID_REQUEST) + return LocalDateTime.of(date, time) + } + + /** + * 결석 판정 경계 시각 = startDateTime + absentStartMinutes + */ + @JvmStatic + fun absentBoundary(session: Session, absentStartMinutes: Long): LocalDateTime = + startDateTime(session).plusMinutes(absentStartMinutes) +} diff --git a/src/main/kotlin/onku/backend/domain/session/validator/SessionTimeValidator.kt b/src/main/kotlin/onku/backend/domain/session/validator/SessionTimeValidator.kt new file mode 100644 index 0000000..56ba9ea --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/validator/SessionTimeValidator.kt @@ -0,0 +1,15 @@ +package onku.backend.domain.session.validator + +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import onku.backend.domain.session.dto.request.UpsertSessionDetailRequest +import onku.backend.domain.session.annotation.SessionValidTimeRange + +class SessionTimeValidator : ConstraintValidator { + override fun isValid(value: UpsertSessionDetailRequest?, context: ConstraintValidatorContext): Boolean { + if (value == null) return true + context.buildConstraintViolationWithTemplate("시작시간이 끝 시간보다 앞설 수 없습니다.") + .addPropertyNode("startTime").addConstraintViolation() + return value.startTime.isBefore(value.endTime) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt b/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt new file mode 100644 index 0000000..2c1f7ee --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt @@ -0,0 +1,37 @@ +package onku.backend.domain.session.validator + +import onku.backend.domain.session.Session +import onku.backend.domain.session.enums.SessionCategory +import org.springframework.stereotype.Component +import java.time.DayOfWeek +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.temporal.TemporalAdjusters + +@Component +class SessionValidator { + + private val zone: ZoneId = ZoneId.of("Asia/Seoul") + + /** 지난 세션 여부 */ + fun isPastSession(session: Session, now: LocalDateTime = LocalDateTime.now(zone)): Boolean { + return session.startDate.isBefore(now.toLocalDate()) + } + + /** 금/토에 바로 앞 토요일 세션인지 여부 */ + fun isImminentSession(session: Session, now: LocalDateTime = LocalDateTime.now(zone)): Boolean { + val sessionDate = session.startDate // 이미 LocalDate 타입 + val today = now.toLocalDate() + + // 세션이 속한 주의 목요일 계산 + val sessionThursday = sessionDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.THURSDAY)) + + // 목요일 00:00부터는 불가 → 목요일보다 이전일 때만 true + return today.isBefore(sessionThursday) + } + + /** 휴회 세션인지 여부 */ + fun isRestSession(session: Session) : Boolean{ + return (session.category == SessionCategory.REST) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/alarm/AlarmMessage.kt b/src/main/kotlin/onku/backend/global/alarm/AlarmMessage.kt new file mode 100644 index 0000000..7627d0b --- /dev/null +++ b/src/main/kotlin/onku/backend/global/alarm/AlarmMessage.kt @@ -0,0 +1,16 @@ +package onku.backend.global.alarm + +import onku.backend.domain.absence.enums.AbsenceApprovedType + +object AlarmMessage { + fun kupick(month : Int, status : Boolean): String { + if(status) { + return "신청하신 ${month}월 큐픽이 승인되었어요" + } + return "신청하신 ${month}월 큐픽이 반려되었어요" + } + + fun absenceReport(month: Int, day : Int, absenceApprovedType: AbsenceApprovedType) : String { + return "${month}월 ${day}일 세션 불참사유서가 처리되어 벌점 ${absenceApprovedType.points}점이 기록되었습니다." + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/alarm/FCMMessage.kt b/src/main/kotlin/onku/backend/global/alarm/FCMMessage.kt new file mode 100644 index 0000000..66f4ff8 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/alarm/FCMMessage.kt @@ -0,0 +1,31 @@ +package onku.backend.global.alarm + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +data class FCMMessage( + val validateOnly: Boolean, + val message: Message +) { + data class Message( + val notification: Notification, + val token: String, + val webpush : Webpush? + ) + + data class Notification( + val title: String, + val body: String, + val image: String? + ) + @Serializable + data class Webpush( + @SerialName("fcm_options") + val fcmOptions : FcmOptions? + ) + + @Serializable + data class FcmOptions( + val link : String? + ) +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/alarm/FCMService.kt b/src/main/kotlin/onku/backend/global/alarm/FCMService.kt new file mode 100644 index 0000000..4bdc813 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/alarm/FCMService.kt @@ -0,0 +1,99 @@ +package onku.backend.global.alarm + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.auth.oauth2.GoogleCredentials +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import onku.backend.domain.member.Member +import onku.backend.domain.member.service.MemberAlarmHistoryService +import onku.backend.global.alarm.enums.AlarmEmojiType +import onku.backend.global.alarm.enums.AlarmTitleType +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.io.ClassPathResource +import org.springframework.http.HttpHeaders +import org.springframework.stereotype.Service +import java.io.IOException + +@Service +class FCMService( + private val objectMapper: ObjectMapper, + private val memberAlarmHistoryService: MemberAlarmHistoryService, + @Value("\${fcm.api-url}") + private val API_URL : String, + @Value("\${fcm.firebase-config-path}") + private val firebaseConfigPath : String, + @Value("\${fcm.google.scope}") + private val googleScope : String +) { + private val log: Logger = LoggerFactory.getLogger(FCMService::class.java) + companion object { + private val client: OkHttpClient = OkHttpClient() + } + @Throws(IOException::class) + fun sendMessageTo(member: Member, alarmTitleType : AlarmTitleType, alarmEmojiType: AlarmEmojiType, body: String, link: String?) { + if(member.fcmToken.isNullOrBlank()) { + return + } + val title = alarmTitleType.title + val message = makeMessage(member.fcmToken!!, title, body, link) + + val requestBody = message + .toRequestBody("application/json; charset=utf-8".toMediaType()) + + val request = Request.Builder() + .url(API_URL) + .post(requestBody) + .addHeader(HttpHeaders.AUTHORIZATION, "Bearer ${getAccessToken()}") + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8") + .build() + + log.info("제목 : $title, 내용 : $body") + + memberAlarmHistoryService.saveAlarm(member, alarmEmojiType, body) + + client.newCall(request).execute().use { response -> + log.info("fcm 결과 : " + response.body?.string()) + } + } + + @Throws(JsonProcessingException::class) + private fun makeMessage(targetToken: String, title: String, body: String, link:String?): String { + val fcmMessage = FCMMessage( + validateOnly = false, + message = FCMMessage.Message( + token = targetToken, + notification = FCMMessage.Notification( + title = title, + body = body, + image = null + ), + webpush = FCMMessage.Webpush( + FCMMessage.FcmOptions(link) + ) + ) + ) + return objectMapper.writeValueAsString(fcmMessage) + } + + @Throws(IOException::class) + private fun getAccessToken(): String { + try { + val googleCredentials = GoogleCredentials + .fromStream(ClassPathResource(firebaseConfigPath).inputStream) + .createScoped(listOf(googleScope)) + + googleCredentials.refreshIfExpired() + return googleCredentials.accessToken.tokenValue + } catch (e: IOException) { + log.error("FCM AccessToken 발급 중 오류 발생", e) + throw CustomException(ErrorCode.FCM_ACCESS_TOKEN_FAIL) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/alarm/enums/AlarmEmojiType.kt b/src/main/kotlin/onku/backend/global/alarm/enums/AlarmEmojiType.kt new file mode 100644 index 0000000..98e1d02 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/alarm/enums/AlarmEmojiType.kt @@ -0,0 +1,7 @@ +package onku.backend.global.alarm.enums + +enum class AlarmEmojiType { + MEGAPHONE, + STAR, + WARNING +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/alarm/enums/AlarmTitleType.kt b/src/main/kotlin/onku/backend/global/alarm/enums/AlarmTitleType.kt new file mode 100644 index 0000000..632ed5f --- /dev/null +++ b/src/main/kotlin/onku/backend/global/alarm/enums/AlarmTitleType.kt @@ -0,0 +1,8 @@ +package onku.backend.global.alarm.enums + +enum class AlarmTitleType( + val title: String, +) { + KUPICK("큐픽 관련 알림입니다."), + ABSENCE_REPORT("불참 사유서 관련 알림입니다."), +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/annotation/CurrentMember.kt b/src/main/kotlin/onku/backend/global/annotation/CurrentMember.kt new file mode 100644 index 0000000..29a5e0e --- /dev/null +++ b/src/main/kotlin/onku/backend/global/annotation/CurrentMember.kt @@ -0,0 +1,8 @@ +package onku.backend.global.annotation + +import io.swagger.v3.oas.annotations.Parameter + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +@Parameter(hidden = true) +annotation class CurrentMember diff --git a/src/main/kotlin/onku/backend/global/annotation/resolver/AuthenticatedMemberResolver.kt b/src/main/kotlin/onku/backend/global/annotation/resolver/AuthenticatedMemberResolver.kt new file mode 100644 index 0000000..3d1d15b --- /dev/null +++ b/src/main/kotlin/onku/backend/global/annotation/resolver/AuthenticatedMemberResolver.kt @@ -0,0 +1,35 @@ +package onku.backend.global.annotation.resolver + +import onku.backend.domain.member.Member +import onku.backend.domain.member.service.MemberService +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.auth.jwt.JwtUtil +import org.springframework.core.MethodParameter +import org.springframework.stereotype.Component +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +@Component +class AuthenticatedMemberResolver( + private val jwtUtil: JwtUtil, + private val memberService: MemberService +) : HandlerMethodArgumentResolver { + + override fun supportsParameter(parameter: MethodParameter): Boolean = + parameter.hasParameterAnnotation(CurrentMember::class.java) && + Member::class.java.isAssignableFrom(parameter.parameterType) + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory? + ): Any? { + val header = webRequest.getHeader("Authorization") ?: return null + val token = header.split(" ").getOrNull(1) ?: return null + val email = jwtUtil.getEmail(token) + return memberService.getByEmail(email) + } +} diff --git a/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt b/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt new file mode 100644 index 0000000..3580e00 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt @@ -0,0 +1,23 @@ +package onku.backend.global.auth + +import onku.backend.global.exception.ApiErrorCode +import org.springframework.http.HttpStatus + +enum class AuthErrorCode( + override val errorCode: String, + override val message: String, + override val status: HttpStatus +) : ApiErrorCode { + + OAUTH_EMAIL_SCOPE_REQUIRED("AUTH400", "응답값에 이메일이 없습니다.", HttpStatus.BAD_REQUEST), + INVALID_REFRESH_TOKEN("AUTH401", "유효하지 않은 리프레시 토큰입니다.", HttpStatus.UNAUTHORIZED), + EXPIRED_REFRESH_TOKEN("AUTH401", "만료된 리프레시 토큰입니다.", HttpStatus.UNAUTHORIZED), + KAKAO_TOKEN_EMPTY_RESPONSE("AUTH502", "카카오 토큰 응답이 비어 있습니다.", HttpStatus.BAD_GATEWAY), + KAKAO_PROFILE_EMPTY_RESPONSE("AUTH502", "카카오 프로필 응답이 비어 있습니다.", HttpStatus.BAD_GATEWAY), + KAKAO_API_COMMUNICATION_ERROR("AUTH502", "카카오 API 통신 중 오류가 발생했습니다.", HttpStatus.BAD_GATEWAY), + INVALID_REDIRECT_URI("AUTH400", "유효하지 않은 리다이렉트 URI입니다.", HttpStatus.BAD_REQUEST), + APPLE_API_COMMUNICATION_ERROR("AUTH502", "애플 API 통신 중 오류가 발생했습니다.", HttpStatus.BAD_GATEWAY), + APPLE_TOKEN_EMPTY_RESPONSE("AUTH502", "애플 토큰 응답이 비어 있습니다.", HttpStatus.BAD_GATEWAY), + APPLE_ID_TOKEN_INVALID("AUTH401", "유효하지 않은 애플 ID 토큰입니다.", HttpStatus.UNAUTHORIZED), + APPLE_JWKS_FETCH_FAILED("AUTH502", "애플 공개 키(JWKS)를 불러오는 데 실패했습니다.", HttpStatus.BAD_GATEWAY) +} diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt new file mode 100644 index 0000000..535c41a --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -0,0 +1,83 @@ +package onku.backend.global.auth.config + +import onku.backend.domain.member.enums.Role +import onku.backend.global.auth.jwt.JwtFilter +import onku.backend.global.auth.jwt.JwtUtil +import onku.backend.global.config.CustomCorsConfig +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val jwtUtil: JwtUtil +) { + companion object { + private val ALLOWED_GET = arrayOf( + "/swagger-ui/**", + "/v3/api-docs/**", + "/health", + "/actuator/health", + "/test/push/**", + "/api/v1/session/{sessionId}/time" + ) + private val ALLOWED_POST = arrayOf( + "/api/v1/auth/kakao", + "/api/v1/auth/apple", + "/api/v1/auth/reissue", + ) + + private val ONBOARDING_ENDPOINT = arrayOf( // (GUEST) + "/api/v1/members/onboarding", + "/api/v1/members/profile/image/url" + ) + + private val STAFF_ENDPOINT = arrayOf( // 운영진 (STAFF) + "/api/v1/session/staff/**", + "/api/v1/members/staff/**", + "/api/v1/notice/manage/**", + ) + + private val MANAGEMENT_ENDPOINT = arrayOf( // 경총 (MANAGEMENT) + "/api/v1/kupick/manage/**", + "/api/v1/points/manage/**", + "/api/v1/attendance/manage/**", + "/api/v1/absence/manage/**" + ) + + private val EXECUTIVE = arrayOf( // 회장단 (EXECUTIVE) + "/api/v1/members/executive/**", + ) + } + + @Bean + fun filterChain(http: HttpSecurity, corsConfiguration: CustomCorsConfig): SecurityFilterChain { + http + .csrf { it.disable() } + .cors{it.configurationSource( + corsConfiguration.corsConfigurationSource() + )} + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { + it + // 공개 엔드포인트 + .requestMatchers(*ALLOWED_GET).permitAll() + .requestMatchers(*ALLOWED_POST).permitAll() + + // 권한 별 엔드포인트 + .requestMatchers(*ONBOARDING_ENDPOINT).hasRole(Role.GUEST.name) + .requestMatchers(*STAFF_ENDPOINT).hasRole(Role.STAFF.name) + .requestMatchers(*MANAGEMENT_ENDPOINT).hasRole(Role.MANAGEMENT.name) + .requestMatchers(*EXECUTIVE).hasAnyRole(Role.EXECUTIVE.name) + .anyRequest().hasRole(Role.USER.name) + } + .addFilterBefore(JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter::class.java) + + return http.build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt new file mode 100644 index 0000000..7c2fe16 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt @@ -0,0 +1,54 @@ +package onku.backend.global.auth.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.member.Member +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.auth.dto.AppleLoginRequest +import onku.backend.global.auth.dto.AuthLoginResult +import onku.backend.global.auth.dto.KakaoLoginRequest +import onku.backend.global.auth.service.AuthService +import onku.backend.global.response.SuccessResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/auth") +@Tag(name = "인증 API", description = "소셜 로그인 및 토큰 재발급") +class AuthController( + private val authService: AuthService, +) { + @PostMapping("/kakao") + @Operation(summary = "카카오 로그인", description = "인가코드를 body로 받아 사용자를 식별합니다.") + fun kakaoLogin(@RequestBody req: KakaoLoginRequest): ResponseEntity> = + authService.kakaoLogin(req) + + @PostMapping("/apple") + @Operation(summary = "애플 로그인", description = "인가코드를 body로 받아 사용자를 식별합니다.") + fun appleLogin(@RequestBody req: AppleLoginRequest): ResponseEntity> = + authService.appleLogin(req) + + @PostMapping("/reissue") + @Operation(summary = "AT 재발급", description = "RT를 헤더로 받아 AT를 재발급합니다.") + fun reissue(@RequestHeader("X-Refresh-Token") refreshToken: String): ResponseEntity> = + authService.reissueAccessToken(refreshToken) + + @PostMapping("/logout") + @Operation( + summary = "로그아웃", + description = "로그아웃 처리를 위해 X-Refresh-Token 헤더로 받아온 RT를 서버 저장소(REDIS)에서 삭제합니다." + ) + fun logout(@RequestHeader("X-Refresh-Token") refreshToken: String): ResponseEntity> = + authService.logout(refreshToken) + + @PostMapping("/withdraw") + @Operation( + summary = "회원 탈퇴", + description = "카카오 unlink 요청 + DB 회원 삭제 + RT 삭제" + ) + fun withdraw( + @CurrentMember member: Member, + @RequestHeader("X-Refresh-Token") refreshToken: String + ): ResponseEntity> = + authService.withdraw(member, refreshToken) +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/dto/AppleLoginRequest.kt b/src/main/kotlin/onku/backend/global/auth/dto/AppleLoginRequest.kt new file mode 100644 index 0000000..f55aa87 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/AppleLoginRequest.kt @@ -0,0 +1,5 @@ +package onku.backend.global.auth.dto + +data class AppleLoginRequest( + val code: String +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/dto/AuthHeaders.kt b/src/main/kotlin/onku/backend/global/auth/dto/AuthHeaders.kt new file mode 100644 index 0000000..7a52cf3 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/AuthHeaders.kt @@ -0,0 +1,7 @@ +package onku.backend.global.auth.dto + +data class AuthHeaders( + val accessToken: String? = null, + val refreshToken: String? = null, + val onboardingToken: String? = null +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt b/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt new file mode 100644 index 0000000..1317c1c --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt @@ -0,0 +1,11 @@ +package onku.backend.global.auth.dto + +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Role + +data class AuthLoginResult( + val status: ApprovalStatus, + val memberId: Long? = null, + val role: Role? = null, + val hasInfo: Boolean = false, +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/dto/KakaoAccount.kt b/src/main/kotlin/onku/backend/global/auth/dto/KakaoAccount.kt new file mode 100644 index 0000000..214df6b --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/KakaoAccount.kt @@ -0,0 +1,11 @@ +package onku.backend.global.auth.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoAccount( + @JsonProperty("email") val email: String?, + @JsonProperty("has_email") val hasEmail: Boolean? = null, + @JsonProperty("email_needs_agreement") val emailNeedsAgreement: Boolean? = null, + @JsonProperty("is_email_valid") val isEmailValid: Boolean? = null, + @JsonProperty("is_email_verified") val isEmailVerified: Boolean? = null +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginRequest.kt b/src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginRequest.kt new file mode 100644 index 0000000..a006276 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginRequest.kt @@ -0,0 +1,8 @@ +package onku.backend.global.auth.dto + +import onku.backend.global.auth.enums.KakaoEnv + +data class KakaoLoginRequest( + val code: String, + val env: KakaoEnv +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginResult.kt b/src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginResult.kt new file mode 100644 index 0000000..0b42842 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginResult.kt @@ -0,0 +1,14 @@ +package onku.backend.global.auth.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Role + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class LoginResult( + val status: ApprovalStatus, + val memberId: Long? = null, + val role: Role? = null, + val allowedEndpoint: String? = null, + val expiresInMinutes: Long? = null +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/dto/KakaoOAuthTokenResponse.kt b/src/main/kotlin/onku/backend/global/auth/dto/KakaoOAuthTokenResponse.kt new file mode 100644 index 0000000..2eab21f --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/KakaoOAuthTokenResponse.kt @@ -0,0 +1,10 @@ +package onku.backend.global.auth.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoOAuthTokenResponse( + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("token_type") val tokenType: String?, + @JsonProperty("refresh_token") val refreshToken: String?, + @JsonProperty("expires_in") val expiresIn: Long? +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/dto/KakaoProfile.kt b/src/main/kotlin/onku/backend/global/auth/dto/KakaoProfile.kt new file mode 100644 index 0000000..d6d0f62 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/KakaoProfile.kt @@ -0,0 +1,8 @@ +package onku.backend.global.auth.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoProfile( + @JsonProperty("id") val id: Long, + @JsonProperty("kakao_account") val kakaoAccount: KakaoAccount? +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/enums/KakaoEnv.kt b/src/main/kotlin/onku/backend/global/auth/enums/KakaoEnv.kt new file mode 100644 index 0000000..8ab2bc9 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/enums/KakaoEnv.kt @@ -0,0 +1,3 @@ +package onku.backend.global.auth.enums + +enum class KakaoEnv { LOCAL, DEV } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/jwt/JwtFilter.kt b/src/main/kotlin/onku/backend/global/auth/jwt/JwtFilter.kt new file mode 100644 index 0000000..90c89b1 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/jwt/JwtFilter.kt @@ -0,0 +1,41 @@ +package onku.backend.global.auth.jwt + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter + +class JwtFilter( + private val jwtUtil: JwtUtil +) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val auth = request.getHeader("Authorization") + if (auth.isNullOrBlank() || !auth.startsWith("Bearer ")) { + filterChain.doFilter(request, response); return + } + + val token = auth.substringAfter(" ").trim() + if (jwtUtil.isExpired(token)) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + return + } + + val email = jwtUtil.getEmail(token) + val roles = jwtUtil.getRoles(token) + + val authorities = buildList { + roles.forEach { add(SimpleGrantedAuthority("ROLE_${it.uppercase()}")) } + } + + val authentication = UsernamePasswordAuthenticationToken(email, null, authorities) + SecurityContextHolder.getContext().authentication = authentication + filterChain.doFilter(request, response) + } +} diff --git a/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt b/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt new file mode 100644 index 0000000..9345fad --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt @@ -0,0 +1,58 @@ +package onku.backend.global.auth.jwt + +import io.jsonwebtoken.Claims +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import onku.backend.domain.member.enums.Role +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.time.Duration +import java.util.* +import javax.crypto.SecretKey + +@Component +class JwtUtil( + @Value("\${jwt.secret}") secret: String, + @Value("\${jwt.access-ttl:30m}") private val accessTtl: Duration, + @Value("\${jwt.refresh-ttl:7d}") private val refreshTtl: Duration +) { + private val key: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)) + + private fun stripBearerPrefix(token: String?): String { + require(!token.isNullOrBlank()) { "Token cannot be null or empty" } + val t = token.trim() + return if (t.startsWith("Bearer ", ignoreCase = true)) t.substring(7).trim() else t + } + + private fun parseClaims(token: String): Claims = + Jwts.parser().verifyWith(key).build().parseSignedClaims(stripBearerPrefix(token)).payload + + fun getEmail(token: String): String = parseClaims(token).get("email", String::class.java) + fun getRoles(token: String): List = parseClaims(token).get("roles", List::class.java)?.map { it.toString() } ?: emptyList() + + fun isExpired(token: String): Boolean = + try { parseClaims(token).expiration?.before(Date()) ?: true } + catch (_: ExpiredJwtException) { true } + + fun createAccessToken(email: String, roles: List = Role.USER.authorities()): String = + createJwt(email, roles, scopes = emptyList(), expiredMs = accessTtl.toMillis()) + + fun createRefreshToken(email: String, roles: List = Role.USER.authorities()): String = + createJwt(email, roles, scopes = emptyList(), expiredMs = refreshTtl.toMillis()) + + fun createOnboardingToken(email: String, minutes: Long = 30): String = + createJwt(email, roles = Role.GUEST.authorities(), scopes = emptyList(), expiredMs = Duration.ofMinutes(minutes).toMillis()) + + private fun createJwt(email: String, roles: List, scopes: List, expiredMs: Long): String { + val now = Date() + val exp = Date(now.time + expiredMs) + return Jwts.builder() + .claims(mapOf("email" to email, "roles" to roles, "scopes" to scopes)) + .issuedAt(now) + .expiration(exp) + .signWith(key) + .compact() + } +} diff --git a/src/main/kotlin/onku/backend/global/auth/service/AppleService.kt b/src/main/kotlin/onku/backend/global/auth/service/AppleService.kt new file mode 100644 index 0000000..98a80fc --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/service/AppleService.kt @@ -0,0 +1,148 @@ +package onku.backend.global.auth.service + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.ECDSASigner +import com.nimbusds.jose.crypto.RSASSAVerifier +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jwt.SignedJWT +import com.nimbusds.jwt.JWTClaimsSet +import onku.backend.global.auth.AuthErrorCode +import onku.backend.global.exception.CustomException +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.client.RestClient +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.interfaces.ECPrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.time.Instant +import java.util.* + +@Service +class AppleService { + private val client = RestClient.create() + + data class AppleTokenResponse( + val access_token: String?, + val token_type: String?, + val expires_in: Long?, + val refresh_token: String?, + val id_token: String? + ) + + data class AppleIdTokenPayload( + val sub: String, + val email: String? + ) + + fun exchangeCodeForToken( + code: String, + clientId: String, + clientSecret: String, + redirectUri: String + ): AppleTokenResponse { + val form: MultiValueMap = LinkedMultiValueMap().apply { + add("grant_type", "authorization_code") + add("code", code) + add("client_id", clientId) + add("client_secret", clientSecret) + add("redirect_uri", redirectUri) + } + + return try { + val res: ResponseEntity = client.post() + .uri("https://appleid.apple.com/auth/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(form) + .retrieve() + .toEntity(AppleTokenResponse::class.java) + + res.body ?: throw CustomException(AuthErrorCode.APPLE_TOKEN_EMPTY_RESPONSE) + } catch (e: Exception) { + throw CustomException(AuthErrorCode.APPLE_API_COMMUNICATION_ERROR) + } + } + + fun createClientSecret( + teamId: String, + clientId: String, + keyId: String, + privateKeyRaw: String + ): String { + val now = Instant.now() + val exp = now.plusSeconds(60 * 5) + + val claims = JWTClaimsSet.Builder() + .issuer(teamId) + .subject(clientId) + .audience("https://appleid.apple.com") + .issueTime(Date.from(now)) + .expirationTime(Date.from(exp)) + .build() + + val header = JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID(keyId) + .build() + + val jwt = SignedJWT(header, claims) + val ecPrivateKey = parseEcPrivateKey(privateKeyRaw) as ECPrivateKey + jwt.sign(ECDSASigner(ecPrivateKey)) + return jwt.serialize() + } + + fun verifyAndParseIdToken(idToken: String, expectedAud: String): AppleIdTokenPayload { + val jwt = SignedJWT.parse(idToken) + val kid = jwt.header.keyID ?: throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + + val jwkSet = try { + JWKSet.load(java.net.URL("https://appleid.apple.com/auth/keys")) + } catch (e: Exception) { + throw CustomException(AuthErrorCode.APPLE_JWKS_FETCH_FAILED) + } + + val jwk = jwkSet.keys.firstOrNull { it.keyID == kid } + ?: throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + + val rsaKey = jwk as? RSAKey ?: throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + val publicKey = rsaKey.toRSAPublicKey() + + val verified = jwt.verify(RSASSAVerifier(publicKey)) + if (!verified) throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + + val claims = jwt.jwtClaimsSet + + if (claims.issuer != "https://appleid.apple.com") { + throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + } + + val audOk = claims.audience?.contains(expectedAud) == true + if (!audOk) throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + + val exp = claims.expirationTime ?: throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + if (exp.before(Date())) throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + + val sub = claims.subject ?: throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + val email = claims.getStringClaim("email") + + return AppleIdTokenPayload(sub = sub, email = email) + } + + private fun parseEcPrivateKey(raw: String): PrivateKey { + val pem = raw.trim() + val base64 = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\n", "\n") + .replace("\n", "") + .trim() + + val decoded = Base64.getDecoder().decode(base64) + val spec = PKCS8EncodedKeySpec(decoded) + val kf = KeyFactory.getInstance("EC") + return kf.generatePrivate(spec) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt new file mode 100644 index 0000000..22fade7 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt @@ -0,0 +1,17 @@ +package onku.backend.global.auth.service + +import onku.backend.domain.member.Member +import onku.backend.global.auth.dto.AppleLoginRequest +import onku.backend.global.auth.dto.AuthLoginResult +import onku.backend.global.auth.dto.KakaoLoginRequest +import onku.backend.global.response.SuccessResponse +import org.springframework.http.ResponseEntity + +interface AuthService { + fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity> + fun appleLogin(dto: AppleLoginRequest): ResponseEntity> + fun reissueAccessToken(refreshToken: String): ResponseEntity> + fun logout(refreshToken: String): ResponseEntity> + fun withdraw(member: Member, refreshToken: String): ResponseEntity> +} + diff --git a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt new file mode 100644 index 0000000..6a1e119 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -0,0 +1,237 @@ +package onku.backend.global.auth.service + +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.SocialType +import onku.backend.domain.member.service.MemberService +import onku.backend.global.auth.AuthErrorCode +import onku.backend.global.auth.dto.AppleLoginRequest +import onku.backend.global.auth.dto.AuthLoginResult +import onku.backend.global.auth.dto.KakaoLoginRequest +import onku.backend.global.auth.jwt.JwtUtil +import onku.backend.global.config.AppleProps +import onku.backend.global.config.KakaoProps +import onku.backend.global.exception.CustomException +import onku.backend.global.redis.cache.RefreshTokenCache +import onku.backend.global.response.SuccessResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Duration + +@Service +@Transactional(readOnly = true) +class AuthServiceImpl( + private val memberService: MemberService, + private val kakaoService: KakaoService, + private val appleService: AppleService, + private val jwtUtil: JwtUtil, + private val refreshTokenCacheUtil: RefreshTokenCache, + @Value("\${jwt.refresh-ttl}") private val refreshTtl: Duration, + @Value("\${jwt.onboarding-ttl}") private val onboardingTtl: Duration, + private val kakaoProps: KakaoProps, + private val appleProps: AppleProps +) : AuthService { + + @Transactional + override fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity> { + val redirectUri = kakaoProps.redirectMap[dto.env] + ?: throw CustomException(AuthErrorCode.INVALID_REDIRECT_URI) + + val token = kakaoService.getAccessToken( + code = dto.code, + redirectUri = redirectUri, + clientId = kakaoProps.clientId + ) + val profile = kakaoService.getProfile(token.accessToken) + + val socialId = profile.id.toString() + val email = profile.kakaoAccount?.email + ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) + + val member = memberService.upsertSocialMember( + email = email, + socialId = socialId, + type = SocialType.KAKAO + ) + + return buildLoginResponse(member) + } + + @Transactional + override fun appleLogin(dto: AppleLoginRequest): ResponseEntity> { + val redirectUri = appleProps.redirectUri + + val clientSecret = appleService.createClientSecret( + teamId = appleProps.teamId, + clientId = appleProps.clientId, + keyId = appleProps.keyId, + privateKeyRaw = appleProps.privateKey + ) + + val tokenRes = appleService.exchangeCodeForToken( + code = dto.code, + clientId = appleProps.clientId, + clientSecret = clientSecret, + redirectUri = redirectUri + ) + + val idToken = tokenRes.id_token + ?: throw CustomException(AuthErrorCode.APPLE_TOKEN_EMPTY_RESPONSE) + + val payload = appleService.verifyAndParseIdToken( + idToken = idToken, + expectedAud = appleProps.clientId + ) + + val member = memberService.upsertSocialMember( + email = payload.email, + socialId = payload.sub, + type = SocialType.APPLE + ) + + return buildLoginResponse(member) + } + + private fun buildLoginResponse(member: Member): ResponseEntity> { + val email = member.email ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) + + return when (member.approval) { + ApprovalStatus.APPROVED -> { + val roles = member.role.authorities() + val access = jwtUtil.createAccessToken(email, roles) + val refresh = jwtUtil.createRefreshToken(email, roles) + refreshTokenCacheUtil.saveRefreshToken(email, refresh, refreshTtl) + + val headers = HttpHeaders().apply { + add(HttpHeaders.AUTHORIZATION, "Bearer $access") + add("X-Refresh-Token", refresh) + } + + ResponseEntity + .status(HttpStatus.OK) + .headers(headers) + .body( + SuccessResponse.ok( + AuthLoginResult( + status = ApprovalStatus.APPROVED, + memberId = member.id, + role = member.role, + hasInfo = member.hasInfo + ) + ) + ) + } + + ApprovalStatus.PENDING -> { + if (member.hasInfo) { + ResponseEntity + .status(HttpStatus.ACCEPTED) + .body( + SuccessResponse.ok( + AuthLoginResult( + status = ApprovalStatus.PENDING, + memberId = member.id, + role = member.role, + hasInfo = true + ) + ) + ) + } else { + val onboarding = jwtUtil.createOnboardingToken(email, onboardingTtl.toMinutes()) + val headers = HttpHeaders().apply { + add(HttpHeaders.AUTHORIZATION, "Bearer $onboarding") + } + ResponseEntity + .status(HttpStatus.OK) + .headers(headers) + .body( + SuccessResponse.ok( + AuthLoginResult( + status = ApprovalStatus.PENDING, + memberId = member.id, + role = member.role, + hasInfo = false + ) + ) + ) + } + } + + ApprovalStatus.REJECTED -> { + ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body( + SuccessResponse.ok( + AuthLoginResult( + status = ApprovalStatus.REJECTED, + memberId = member.id, + role = member.role, + hasInfo = member.hasInfo + ) + ) + ) + } + } + } + + @Transactional(readOnly = true) + override fun reissueAccessToken(refreshToken: String): ResponseEntity> { + if (jwtUtil.isExpired(refreshToken)) { + throw CustomException(AuthErrorCode.EXPIRED_REFRESH_TOKEN) + } + val email = jwtUtil.getEmail(refreshToken) + val stored = refreshTokenCacheUtil.getRefreshToken(email) + ?: throw CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN) + if (stored != refreshToken) throw CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN) + + val member = memberService.getByEmail(email) + if (member.approval != ApprovalStatus.APPROVED) { + throw CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN) + } + + val roles = member.role.authorities() + val newAccess = jwtUtil.createAccessToken(email, roles) + + val headers = HttpHeaders().apply { + add(HttpHeaders.AUTHORIZATION, "Bearer $newAccess") + } + + return ResponseEntity + .status(HttpStatus.OK) + .headers(headers) + .body(SuccessResponse.ok("Access Token이 재발급되었습니다.")) + } + + @Transactional + override fun logout(refreshToken: String): ResponseEntity> { + deleteRefreshTokenBy(refreshToken) + return ResponseEntity + .status(HttpStatus.OK) + .body(SuccessResponse.ok("로그아웃 되었습니다.")) + } + + @Transactional + override fun withdraw(member: Member, refreshToken: String): ResponseEntity> { + if (member.socialType == SocialType.KAKAO) { // 카카오만 탈퇴 시 unlink 수행 + val kakaoId = member.socialId.toLongOrNull() + ?: throw CustomException(AuthErrorCode.KAKAO_API_COMMUNICATION_ERROR) + kakaoService.adminUnlink(kakaoId, kakaoProps.adminKey) + } + + deleteRefreshTokenBy(refreshToken) + val memberId = member.id ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + memberService.deleteMemberById(memberId) + return ResponseEntity.ok(SuccessResponse.ok("회원 탈퇴가 완료되었습니다.")) + } + + private fun deleteRefreshTokenBy(refreshToken: String): String? { + val email = runCatching { jwtUtil.getEmail(refreshToken) }.getOrNull() ?: return null + refreshTokenCacheUtil.deleteRefreshToken(email) + return email + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/service/KakaoService.kt b/src/main/kotlin/onku/backend/global/auth/service/KakaoService.kt new file mode 100644 index 0000000..f1b7b98 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/service/KakaoService.kt @@ -0,0 +1,75 @@ +package onku.backend.global.auth.service + +import onku.backend.global.auth.AuthErrorCode +import onku.backend.global.auth.dto.KakaoOAuthTokenResponse +import onku.backend.global.auth.dto.KakaoProfile +import onku.backend.global.exception.CustomException +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.client.RestClient + +@Service +class KakaoService { + private val client = RestClient.create() + + fun getAccessToken( + code: String, + redirectUri: String, + clientId: String + ): KakaoOAuthTokenResponse { + val params: MultiValueMap = LinkedMultiValueMap().apply { + add("grant_type", "authorization_code") + add("client_id", clientId) + add("redirect_uri", redirectUri) + add("code", code) + } + + return try { + val res: ResponseEntity = client.post() + .uri("https://kauth.kakao.com/oauth/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(params) + .retrieve() + .toEntity(KakaoOAuthTokenResponse::class.java) + + res.body ?: throw CustomException(AuthErrorCode.KAKAO_TOKEN_EMPTY_RESPONSE) + } catch (e: Exception) { + throw CustomException(AuthErrorCode.KAKAO_API_COMMUNICATION_ERROR) + } + } + + fun getProfile(accessToken: String): KakaoProfile { + return try { + val res: ResponseEntity = client.get() + .uri("https://kapi.kakao.com/v2/user/me") + .header("Authorization", "Bearer $accessToken") + .retrieve() + .toEntity(KakaoProfile::class.java) + + res.body ?: throw CustomException(AuthErrorCode.KAKAO_PROFILE_EMPTY_RESPONSE) + } catch (e: Exception) { + throw CustomException(AuthErrorCode.KAKAO_API_COMMUNICATION_ERROR) + } + } + + fun adminUnlink(userId: Long, adminKey: String) { + val form: MultiValueMap = LinkedMultiValueMap().apply { + add("target_id_type", "user_id") + add("target_id", userId.toString()) + } + + try { + client.post() + .uri("https://kapi.kakao.com/v1/user/unlink") + .header("Authorization", "KakaoAK $adminKey") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(form) + .retrieve() + .toBodilessEntity() + } catch (e: Exception) { + throw CustomException(AuthErrorCode.KAKAO_API_COMMUNICATION_ERROR) + } + } +} diff --git a/src/main/kotlin/onku/backend/global/config/AppleProps.kt b/src/main/kotlin/onku/backend/global/config/AppleProps.kt new file mode 100644 index 0000000..31a1f00 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/config/AppleProps.kt @@ -0,0 +1,12 @@ +package onku.backend.global.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth.apple") +data class AppleProps( + val clientId: String, + val teamId: String, + val keyId: String, + val privateKey: String, + val redirectUri: String +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt b/src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt new file mode 100644 index 0000000..a00c6da --- /dev/null +++ b/src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt @@ -0,0 +1,30 @@ +package onku.backend.global.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource + +@Configuration +class CustomCorsConfig { + + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + val configuration = CorsConfiguration() + configuration.allowedOrigins = + listOf( + "http://localhost:3000", + "http://localhost:8080", + "https://dev.ku-check.o-r.kr" + ) + configuration.allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + configuration.allowedHeaders = listOf("*") + configuration.allowCredentials = true + configuration.exposedHeaders = listOf("Authorization", "Set-Cookie", "X-Refresh-Token") + configuration.maxAge = 3600 + val source = UrlBasedCorsConfigurationSource() + source.registerCorsConfiguration("/**", configuration) + return source + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/config/KakaoProps.kt b/src/main/kotlin/onku/backend/global/config/KakaoProps.kt new file mode 100644 index 0000000..cf2416b --- /dev/null +++ b/src/main/kotlin/onku/backend/global/config/KakaoProps.kt @@ -0,0 +1,11 @@ +package onku.backend.global.config + +import onku.backend.global.auth.enums.KakaoEnv +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth.kakao") +data class KakaoProps( + val clientId: String, + val adminKey: String, + val redirectMap: Map = emptyMap() +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/config/PropsConfig.kt b/src/main/kotlin/onku/backend/global/config/PropsConfig.kt new file mode 100644 index 0000000..85002a3 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/config/PropsConfig.kt @@ -0,0 +1,13 @@ +package onku.backend.global.config + +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties( + value = [ + KakaoProps::class, + AppleProps::class + ] +) +class PropsConfig \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/config/RedisLuaConfig.kt b/src/main/kotlin/onku/backend/global/config/RedisLuaConfig.kt new file mode 100644 index 0000000..4d6a45b --- /dev/null +++ b/src/main/kotlin/onku/backend/global/config/RedisLuaConfig.kt @@ -0,0 +1,24 @@ +package onku.backend.global.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.io.ClassPathResource +import org.springframework.data.redis.core.script.DefaultRedisScript + +@Configuration +class RedisLuaConfig { + + @Bean + fun attendanceSwapScript(): DefaultRedisScript = + DefaultRedisScript().apply { + setLocation(ClassPathResource("lua/issue_token.lua")) + setResultType(String::class.java) + } + + @Bean + fun attendanceConsumeScript(): DefaultRedisScript = + DefaultRedisScript().apply { + setLocation(ClassPathResource("lua/consume_token.lua")) + setResultType(String::class.java) + } +} diff --git a/src/main/kotlin/onku/backend/global/config/SwaggerConfig.kt b/src/main/kotlin/onku/backend/global/config/SwaggerConfig.kt new file mode 100644 index 0000000..4a2adaa --- /dev/null +++ b/src/main/kotlin/onku/backend/global/config/SwaggerConfig.kt @@ -0,0 +1,36 @@ +package onku.backend.global.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class SwaggerConfig( + @Value("\${swagger.request-url}") + private val requestUrl: String, +) { + + @Bean + fun openAPI(): OpenAPI { + val securityScheme = SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + + val securityRequirement = SecurityRequirement().addList("bearerAuth") + + val server = Server() + .url(requestUrl) + .description("Production Server") + + return OpenAPI() + .components(Components().addSecuritySchemes("bearerAuth", securityScheme)) + .servers(listOf(server)) + .addSecurityItem(securityRequirement) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/config/TaskSchedulerConfig.kt b/src/main/kotlin/onku/backend/global/config/TaskSchedulerConfig.kt new file mode 100644 index 0000000..a25c92a --- /dev/null +++ b/src/main/kotlin/onku/backend/global/config/TaskSchedulerConfig.kt @@ -0,0 +1,20 @@ +package onku.backend.global.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.TaskScheduler +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler + +@Configuration +@EnableScheduling +class TaskSchedulerConfig { + + @Bean(name = ["AttendanceTaskScheduler"]) + fun onkuTaskScheduler(): TaskScheduler = + ThreadPoolTaskScheduler().apply { + poolSize = 4 + setThreadNamePrefix("attendance-finalize-") + initialize() + } +} diff --git a/src/main/kotlin/onku/backend/global/config/TimeConfig.kt b/src/main/kotlin/onku/backend/global/config/TimeConfig.kt new file mode 100644 index 0000000..91fc12d --- /dev/null +++ b/src/main/kotlin/onku/backend/global/config/TimeConfig.kt @@ -0,0 +1,12 @@ +package onku.backend.global.config + + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.Clock + +@Configuration +class TimeConfig { + @Bean + fun systemClock(): Clock = Clock.systemDefaultZone() +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/config/WebConfig.kt b/src/main/kotlin/onku/backend/global/config/WebConfig.kt new file mode 100644 index 0000000..e2953db --- /dev/null +++ b/src/main/kotlin/onku/backend/global/config/WebConfig.kt @@ -0,0 +1,15 @@ +package onku.backend.global.config + +import onku.backend.global.annotation.resolver.AuthenticatedMemberResolver +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebConfig( + private val authenticatedMemberResolver: AuthenticatedMemberResolver +) : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(authenticatedMemberResolver) + } +} diff --git a/src/main/kotlin/onku/backend/global/context/SpringContext.kt b/src/main/kotlin/onku/backend/global/context/SpringContext.kt new file mode 100644 index 0000000..df79dfb --- /dev/null +++ b/src/main/kotlin/onku/backend/global/context/SpringContext.kt @@ -0,0 +1,20 @@ +package onku.backend.global.context + +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware +import org.springframework.stereotype.Component + +@Component +class SpringContext : ApplicationContextAware { + + override fun setApplicationContext(ctx: ApplicationContext) { + context = ctx + } + + companion object { + private lateinit var context: ApplicationContext + + fun getBean(type: Class): T = + context.getBean(type) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/crypto/AESService.kt b/src/main/kotlin/onku/backend/global/crypto/AESService.kt new file mode 100644 index 0000000..c606879 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/crypto/AESService.kt @@ -0,0 +1,69 @@ +package onku.backend.global.crypto + +import onku.backend.global.crypto.exception.DecryptionException +import onku.backend.global.crypto.exception.EncryptionException +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import java.util.* +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Service +class AESService( + @Value("\${aes.secret-key-base64}") + secretKeyBase64: String +) : PrivacyEncryptor { + + companion object { + private const val AES = "AES" + private const val AES_GCM_NO_PADDING = "AES/GCM/NoPadding" + private const val GCM_IV_LENGTH = 12 // bytes + private const val GCM_TAG_LENGTH_BITS = 128 + } + + private val key: SecretKey = + Base64.getDecoder().decode(secretKeyBase64).let { keyBytes -> + require(keyBytes.size == 16 || keyBytes.size == 24 || keyBytes.size == 32) { + "AES key must be 16/24/32 bytes after Base64 decode." + } + SecretKeySpec(keyBytes, AES) + } + + private val secureRandom = SecureRandom() + + override fun encrypt(raw: String?): String? { + if (raw == null) return null + return try { + val iv = ByteArray(GCM_IV_LENGTH).also { secureRandom.nextBytes(it) } + val cipher = Cipher.getInstance(AES_GCM_NO_PADDING).apply { + init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)) + } + val cipherText = cipher.doFinal(raw.toByteArray(StandardCharsets.UTF_8)) + Base64.getEncoder().encodeToString(iv + cipherText) + } catch (e: Exception) { + throw EncryptionException(e) + } + } + + override fun decrypt(encrypted: String?): String? { + if (encrypted == null) return null + return try { + val decoded = Base64.getDecoder().decode(encrypted) + require(decoded.size > GCM_IV_LENGTH + (GCM_TAG_LENGTH_BITS / 8)) { "Invalid encrypted text" } + + val iv = decoded.copyOfRange(0, GCM_IV_LENGTH) + val cipherText = decoded.copyOfRange(GCM_IV_LENGTH, decoded.size) + + val cipher = Cipher.getInstance(AES_GCM_NO_PADDING).apply { + init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)) + } + String(cipher.doFinal(cipherText), StandardCharsets.UTF_8) + } catch (e: Exception) { + throw DecryptionException(e) + } + } +} diff --git a/src/main/kotlin/onku/backend/global/crypto/PrivacyEncryptor.kt b/src/main/kotlin/onku/backend/global/crypto/PrivacyEncryptor.kt new file mode 100644 index 0000000..dc62c4d --- /dev/null +++ b/src/main/kotlin/onku/backend/global/crypto/PrivacyEncryptor.kt @@ -0,0 +1,8 @@ +package onku.backend.global.crypto + +interface PrivacyEncryptor { + @Throws(Exception::class) + fun encrypt(raw: String?): String? + @Throws(Exception::class) + fun decrypt(encrypted: String?): String? +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt b/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt new file mode 100644 index 0000000..0348bde --- /dev/null +++ b/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt @@ -0,0 +1,34 @@ +package onku.backend.global.crypto.converter; + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter +import onku.backend.global.context.SpringContext +import onku.backend.global.crypto.PrivacyEncryptor +import onku.backend.global.crypto.exception.DecryptionException +import onku.backend.global.crypto.exception.EncryptionException + + +@Converter +class EncryptedStringConverter : AttributeConverter { + private fun encryptor(): PrivacyEncryptor { + return SpringContext.getBean(PrivacyEncryptor::class.java) + } + + override fun convertToDatabaseColumn(raw: String?): String? { + if (raw.isNullOrBlank()) return null + try { + return encryptor().encrypt(raw) + } catch (e: Exception) { + throw EncryptionException(e) + } + } + + override fun convertToEntityAttribute(encrypted: String?): String? { + if (encrypted == null) return null + try { + return encryptor().decrypt(encrypted) + } catch (e: Exception) { + throw DecryptionException(e) + } + } +} diff --git a/src/main/kotlin/onku/backend/global/crypto/exception/CryptoException.kt b/src/main/kotlin/onku/backend/global/crypto/exception/CryptoException.kt new file mode 100644 index 0000000..289d198 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/crypto/exception/CryptoException.kt @@ -0,0 +1,6 @@ +package onku.backend.global.crypto.exception + +open class CryptoException(message: String, cause: Throwable? = null) + : RuntimeException(message, cause) +class EncryptionException(cause: Throwable? = null) : CryptoException("encryption failed", cause) +class DecryptionException(cause: Throwable? = null) : CryptoException("decryption failed", cause) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/entity/BaseEntity.kt b/src/main/kotlin/onku/backend/global/entity/BaseEntity.kt new file mode 100644 index 0000000..28c3a1d --- /dev/null +++ b/src/main/kotlin/onku/backend/global/entity/BaseEntity.kt @@ -0,0 +1,18 @@ +package onku.backend.global.entity + +import jakarta.persistence.Column +import jakarta.persistence.MappedSuperclass +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.UpdateTimestamp +import java.time.LocalDateTime + +@MappedSuperclass +abstract class BaseEntity { + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + lateinit var createdAt: LocalDateTime + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + lateinit var updatedAt: LocalDateTime +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/exception/ApiErrorCode.kt b/src/main/kotlin/onku/backend/global/exception/ApiErrorCode.kt new file mode 100644 index 0000000..6eb29a1 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/exception/ApiErrorCode.kt @@ -0,0 +1,9 @@ +package onku.backend.global.exception + +import org.springframework.http.HttpStatus + +interface ApiErrorCode { + val errorCode: String + val message: String + val status: HttpStatus +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/exception/CustomException.kt b/src/main/kotlin/onku/backend/global/exception/CustomException.kt new file mode 100644 index 0000000..8819f8b --- /dev/null +++ b/src/main/kotlin/onku/backend/global/exception/CustomException.kt @@ -0,0 +1,5 @@ +package onku.backend.global.exception + +class CustomException( + val errorCode: ApiErrorCode +) : RuntimeException(errorCode.message) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt new file mode 100644 index 0000000..a247376 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -0,0 +1,20 @@ +package onku.backend.global.exception + +import org.springframework.http.HttpStatus + +enum class ErrorCode( + override val errorCode: String, + override val message: String, + override val status: HttpStatus +) : ApiErrorCode { + SERVER_UNTRACKED_ERROR("COMMON500", "미등록 서버 에러입니다. 서버 팀에 연락주세요.", HttpStatus.INTERNAL_SERVER_ERROR), + INVALID_REQUEST("COMMON400", "잘못된 요청입니다.", HttpStatus.BAD_REQUEST), + UNAUTHORIZED("COMMON401", "인증되지 않은 요청입니다.", HttpStatus.UNAUTHORIZED), + FORBIDDEN("COMMON403", "권한이 부족합니다.", HttpStatus.FORBIDDEN), + INVALID_PARAMETER("COMMON422", "잘못된 파라미터입니다.", HttpStatus.UNPROCESSABLE_ENTITY), + PARAMETER_VALIDATION_ERROR("COMMON422", "파라미터 검증 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), + PARAMETER_GRAMMAR_ERROR("COMMON422", "파라미터 문법 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), + INVALID_FILE_EXTENSION("S3001", "올바르지 않은 파일 확장자 입니다.", HttpStatus.BAD_REQUEST), + FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST), + SQL_INTEGRITY_VIOLATION("sql001", "무결성 제약조건을 위반하였습니다.", HttpStatus.BAD_REQUEST); +} diff --git a/src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt b/src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt new file mode 100644 index 0000000..df5a16d --- /dev/null +++ b/src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt @@ -0,0 +1,201 @@ +package onku.backend.global.exception + +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.ConstraintViolationException +import onku.backend.global.crypto.exception.CryptoException +import onku.backend.global.response.ErrorResponse +import onku.backend.global.response.result.ExceptionResult +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class ExceptionAdvice { + + private val log = LoggerFactory.getLogger(javaClass) + + + /** + * 등록되지 않은 에러 + */ + @ExceptionHandler(Exception::class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + fun handleUntrackedException( + e: Exception, + req: HttpServletRequest + ): ErrorResponse { + val serverErrorData = ExceptionResult.ServerErrorData( + errorClass = e.javaClass.name, + errorMessage = e.message + ) + + return ErrorResponse.ok( + ErrorCode.SERVER_UNTRACKED_ERROR.errorCode, + ErrorCode.SERVER_UNTRACKED_ERROR.message, + serverErrorData + ) + } + + /** + * 파라미터 검증 예외 (@Valid, @Validated) + */ + @ExceptionHandler(MethodArgumentNotValidException::class) + @ResponseStatus(HttpStatus.PRECONDITION_FAILED) + fun handleValidationExceptions( + e: MethodArgumentNotValidException + ): ErrorResponse> { + val list = e.bindingResult.fieldErrors.map { fieldError: FieldError -> + ExceptionResult.ParameterData( + key = fieldError.field, + value = fieldError.rejectedValue?.toString(), + reason = fieldError.defaultMessage + ) + } + + return ErrorResponse.ok( + ErrorCode.PARAMETER_VALIDATION_ERROR.errorCode, + ErrorCode.PARAMETER_VALIDATION_ERROR.message, + list + ) + } + + /** + * 파라미터 문법 예외 (JSON 파싱 등) + */ + @ExceptionHandler(HttpMessageNotReadableException::class) + @ResponseStatus(HttpStatus.PRECONDITION_FAILED) + fun handleHttpMessageParsingExceptions( + e: HttpMessageNotReadableException + ): ErrorResponse { + return ErrorResponse.ok( + ErrorCode.PARAMETER_GRAMMAR_ERROR.errorCode, + ErrorCode.PARAMETER_GRAMMAR_ERROR.message, + e.message + ) + } + + /** + * 요청 파라미터(@RequestParam 등) 유효성 검증 실패 예외 + */ + @ExceptionHandler(ConstraintViolationException::class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + fun handleConstraintViolationException( + e: ConstraintViolationException + ): ErrorResponse> { + val list = e.constraintViolations.map { violation -> + val fieldPath = violation.propertyPath.toString() // 예: "checkEmailDuplicate.email" + val field = if ('.' in fieldPath) { + fieldPath.substring(fieldPath.lastIndexOf('.') + 1) + } else { + fieldPath + } + + val value = violation.invalidValue?.toString() ?: "null" + val reason = violation.message + + ExceptionResult.ParameterData( + key = field, + value = value, + reason = reason + ) + } + + return ErrorResponse.ok( + ErrorCode.PARAMETER_VALIDATION_ERROR.errorCode, + ErrorCode.PARAMETER_VALIDATION_ERROR.message, + list + ) + } + + /** + * 커스텀 예외 + */ + @ExceptionHandler(CustomException::class) + fun handleCustomException( + e: CustomException + ): ResponseEntity> { + val code = e.errorCode + val body = ErrorResponse.of(code.errorCode, code.message) + return ResponseEntity(body, code.status) + } + + @ExceptionHandler( + org.springframework.dao.DataIntegrityViolationException::class, + org.springframework.dao.DuplicateKeyException::class, + org.hibernate.exception.ConstraintViolationException::class, + java.sql.SQLIntegrityConstraintViolationException::class + ) + fun handleIntegrityViolation(e: Exception): ResponseEntity> { + val code = ErrorCode.SQL_INTEGRITY_VIOLATION + val body = ErrorResponse.of(code.errorCode, code.message) + return ResponseEntity(body, code.status) + } + + /** + * 암호화 관련 에러 + */ + @ExceptionHandler(CryptoException::class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + fun handleCryptoException(e: CryptoException, req: HttpServletRequest): ErrorResponse { + + // ✅ 민감정보(raw/encrypted) 절대 로그에 찍지 말기 + // ✅ 대신 요청정보/경로/메서드/원인 예외 타입 정도만 + log.error( + "[CRYPTO_ERROR] {} {} uri={} remote={} ua={} cause={}", + req.method, + req.requestURI, + req.requestURL, + req.remoteAddr, + req.getHeader("User-Agent"), + e.cause?.javaClass?.name ?: "none", + e + ) + + val serverErrorData = ExceptionResult.ServerErrorData( + errorClass = null, + errorMessage = "internal server error" // 암호화 관련 에러인건 프론트에 숨기기 + ) + + return ErrorResponse.ok( + ErrorCode.SERVER_UNTRACKED_ERROR.errorCode, + ErrorCode.SERVER_UNTRACKED_ERROR.message, + serverErrorData + ) + } + + @ExceptionHandler(org.springframework.orm.jpa.JpaSystemException::class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + fun handleJpaSystemException( + e: org.springframework.orm.jpa.JpaSystemException, + req: HttpServletRequest + ): ErrorResponse { + + val isConverter = e.message?.contains("AttributeConverter", ignoreCase = true) == true + + if (isConverter) { + log.error("[CRYPTO_ERROR][JPA_CONVERTER] {} {} class={} msg={}", + req.method, req.requestURI, e.javaClass.name, e.message, e + ) + } else { + log.error("[JPA_ERROR] {} {}", req.method, req.requestURI, e) + } + + val serverErrorData = ExceptionResult.ServerErrorData( + errorClass = "INTERNAL_SERVER_ERROR", + errorMessage = "internal server error" + ) + + return ErrorResponse.ok( + ErrorCode.SERVER_UNTRACKED_ERROR.errorCode, + ErrorCode.SERVER_UNTRACKED_ERROR.message, + serverErrorData + ) + } + +} diff --git a/src/main/kotlin/onku/backend/global/page/PageResponse.kt b/src/main/kotlin/onku/backend/global/page/PageResponse.kt new file mode 100644 index 0000000..04b602a --- /dev/null +++ b/src/main/kotlin/onku/backend/global/page/PageResponse.kt @@ -0,0 +1,29 @@ +package onku.backend.global.page + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.data.domain.Page + +data class PageResponse( + @Schema(description = "페이지 응답") + val data: List, + + @Schema(description = "전체 페이지 수") + val totalPages: Int, + + @Schema(description = "전체 아이템 개수") + val totalElements: Long, + + @Schema(description = "마지막 페이지 여부") + val isLastPage: Boolean, +) { + companion object { + fun from( + page: Page + ): PageResponse = PageResponse( + data = page.content, + totalPages = page.totalPages, + totalElements = page.totalElements, + isLastPage = page.isLast + ) + } +} diff --git a/src/main/kotlin/onku/backend/global/redis/cache/AttendanceTokenCache.kt b/src/main/kotlin/onku/backend/global/redis/cache/AttendanceTokenCache.kt new file mode 100644 index 0000000..9c8e8a6 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/redis/cache/AttendanceTokenCache.kt @@ -0,0 +1,86 @@ +package onku.backend.global.redis.cache + +import onku.backend.global.redis.dto.TokenData +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.dao.DataAccessException +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.script.DefaultRedisScript +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class AttendanceTokenCache( + private val redis: StringRedisTemplate, + @Qualifier("attendanceSwapScript") + private val swapScript: DefaultRedisScript, + @Qualifier("attendanceConsumeScript") + private val consumeScript: DefaultRedisScript +) { + private val zone: ZoneId = ZoneId.of("UTC") + + private companion object { + private const val TOKEN_PREFIX = "attendance:token:" + private const val MEMBER_PREFIX = "attendance:member:" + private const val ACTIVE_SUFFIX = ":active" + + fun tokenKey(token: String) = "$TOKEN_PREFIX$token" + fun memberKey(memberId: Long) = "$MEMBER_PREFIX$memberId$ACTIVE_SUFFIX" + } + + private fun execScript( + script: DefaultRedisScript, + keys: List, + vararg args: String + ): T? = try { + redis.execute(script, keys, *args) + } catch (e: DataAccessException) { + null + } + + /** + * 새 토큰을 발급하고 활성 토큰을 교체 + * 이전 활성 토큰이 있으면 used=1 + TTL 상한(usedGraceSeconds)으로 수정해서 active token은 단일이도록 보장 + */ + fun putAsActiveSingle( + memberId: Long, + token: String, + issuedAt: LocalDateTime, + expAt: LocalDateTime, + ttlSeconds: Long, + usedGraceSeconds: Long? = null + ) { + val payload = TokenData(memberId, issuedAt, expAt, used = false).toPayload(zone) + val keys = listOf( + memberKey(memberId), // KEYS[1] + TOKEN_PREFIX, // KEYS[2] + tokenKey(token) // KEYS[3] + ) + val baseArgs = mutableListOf( + payload, // ARGV[1] + ttlSeconds.toString(), // ARGV[2] + token // ARGV[3] + ) + usedGraceSeconds?.let { baseArgs.add(it.toString()) } + + execScript(swapScript, keys, *baseArgs.toTypedArray()) + ?: error("Failed to execute swapScript for token=$token") + } + + /** + * 토큰을 소비(출석체크 성공)하면서 used=1로 수정하고 TTL을 상한(usedGraceSeconds) 이하로 수정 + */ + fun consumeToken(token: String, usedGraceSeconds: Long = 30): TokenData? { + val keys = listOf(tokenKey(token)) + val raw: String? = execScript(consumeScript, keys, usedGraceSeconds.toString()) + return TokenData.parse(raw, zone) + } + + /** 멤버의 현재 활성 토큰 문자열을 조회 */ + fun getActiveTokenOf(memberId: Long): String? = + redis.opsForValue().get(memberKey(memberId)) + + /** 토큰 payload 확인 → 해당 member가 이미 세션에 출석했는지 여부를 검사하기 위해 */ + fun peek(token: String): TokenData? = + TokenData.parse(redis.opsForValue().get(tokenKey(token)), zone) +} diff --git a/src/main/kotlin/onku/backend/global/redis/cache/RefreshTokenCache.kt b/src/main/kotlin/onku/backend/global/redis/cache/RefreshTokenCache.kt new file mode 100644 index 0000000..33a2c1c --- /dev/null +++ b/src/main/kotlin/onku/backend/global/redis/cache/RefreshTokenCache.kt @@ -0,0 +1,25 @@ +package onku.backend.global.redis.cache + +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.time.Duration + +@Component +class RefreshTokenCache( + private val redisTemplate: RedisTemplate +) { + companion object { + private const val PREFIX = "refresh_token:" + } + + fun saveRefreshToken(email: String, refreshToken: String, ttl: Duration) { + redisTemplate.opsForValue().set(PREFIX + email, refreshToken, ttl) + } + + fun getRefreshToken(email: String): String? = + redisTemplate.opsForValue().get(PREFIX + email) + + fun deleteRefreshToken(email: String) { + redisTemplate.delete(PREFIX + email) + } +} diff --git a/src/main/kotlin/onku/backend/global/redis/dto/TokenData.kt b/src/main/kotlin/onku/backend/global/redis/dto/TokenData.kt new file mode 100644 index 0000000..8428148 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/redis/dto/TokenData.kt @@ -0,0 +1,39 @@ +package onku.backend.global.redis.dto + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +data class TokenData( + val memberId: Long, + val issuedAt: LocalDateTime, + val expAt: LocalDateTime, + val used: Boolean +) { + // TokenData 객체를 문자열로 직렬화해서 Redis에 저장 + fun toPayload(zone: ZoneId = ZoneId.of("UTC")): String { + val issuedMs = issuedAt.atZone(zone).toInstant().toEpochMilli() + val expMs = expAt.atZone(zone).toInstant().toEpochMilli() + val usedFlag = if (used) 1 else 0 + return "$memberId|$issuedMs|$expMs|$usedFlag" + } + + companion object { + // redis에서 꺼낸 값을 TokenData 객체로 변경 + fun parse(raw: String?, zone: ZoneId = ZoneId.of("UTC")): TokenData? = runCatching { + val s = raw?.trim().orEmpty() + if (s.isEmpty()) return null + val p = s.split('|') + if (p.size != 4) return null + + val memberId = p[0].toLong() + val issuedMs = p[1].toLong() + val expMs = p[2].toLong() + val used = p[3] == "1" + + val issuedAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(issuedMs), zone) + val expAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(expMs), zone) + TokenData(memberId, issuedAt, expAt, used) + }.getOrNull() + } +} diff --git a/src/main/kotlin/onku/backend/global/response/ErrorResponse.kt b/src/main/kotlin/onku/backend/global/response/ErrorResponse.kt new file mode 100644 index 0000000..473a382 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/response/ErrorResponse.kt @@ -0,0 +1,25 @@ +package onku.backend.global.response + +import io.swagger.v3.oas.annotations.media.Schema + +class ErrorResponse( + @Schema(description = "예외 코드", example = "COMMON500") + val code: String, + + @Schema(description = "예외 메세지", example = "실패하였습니다.") + val message: String, + + @Schema(description = "예외 참고 데이터") + val result: T? = null +) { + @Schema(description = "성공 여부", example = "false") + val isSuccess: Boolean = false + + companion object { + fun of(code: String, message: String): ErrorResponse = + ErrorResponse(code = code, message = message, result = null) + + fun ok(code: String, message: String, data: T?): ErrorResponse = + ErrorResponse(code = code, message = message, result = data) + } +} diff --git a/src/main/kotlin/onku/backend/global/response/SuccessResponse.kt b/src/main/kotlin/onku/backend/global/response/SuccessResponse.kt new file mode 100644 index 0000000..aa69dbb --- /dev/null +++ b/src/main/kotlin/onku/backend/global/response/SuccessResponse.kt @@ -0,0 +1,26 @@ +package onku.backend.global.response + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.global.response.result.ResponseState + +class SuccessResponse( + @Schema(description = "상태 코드", example = "1") + val code: Int, + + @Schema(description = "응답 메세지", example = "성공하였습니다.") + val message: String, + + @Schema(description = "응답 데이터") + val result: T +) { + @Schema(description = "성공 여부", example = "true") + val isSuccess: Boolean = true + + companion object { + fun of(code: Int, message: String, data: T): SuccessResponse = + SuccessResponse(code = code, message = message, result = data) + + fun ok(data: T): SuccessResponse = + of(ResponseState.SUCCESS.code, ResponseState.SUCCESS.message, data) + } +} diff --git a/src/main/kotlin/onku/backend/global/response/result/ExceptionResult.kt b/src/main/kotlin/onku/backend/global/response/result/ExceptionResult.kt new file mode 100644 index 0000000..36b6376 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/response/result/ExceptionResult.kt @@ -0,0 +1,25 @@ +package onku.backend.global.response.result + +import io.swagger.v3.oas.annotations.media.Schema + +class ExceptionResult { + + data class ServerErrorData( + @Schema(description = "오류 발생 클래스", example = "org.example.XX") + val errorClass: String? = null, + + @Schema(description = "오류 메세지") + val errorMessage: String? = null + ) + + data class ParameterData( + @Schema(description = "오류가 발생한 필드", example = "title") + val key: String? = null, + + @Schema(description = "넣은 요청값", example = "null") + val value: String? = null, + + @Schema(description = "오류 발생 이유", example = "공백일 수 없습니다") + val reason: String? = null + ) +} diff --git a/src/main/kotlin/onku/backend/global/response/result/ResponseState.kt b/src/main/kotlin/onku/backend/global/response/result/ResponseState.kt new file mode 100644 index 0000000..2604964 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/response/result/ResponseState.kt @@ -0,0 +1,9 @@ +package onku.backend.global.response.result + +enum class ResponseState( + val code: Int, + val message: String +) { + SUCCESS(1, "성공하였습니다."), + FAIL(-1, "실패하였습니다."); +} diff --git a/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt b/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt new file mode 100644 index 0000000..2e14a67 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt @@ -0,0 +1,60 @@ +package onku.backend.global.s3.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration +import software.amazon.awssdk.core.retry.RetryPolicy +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient +import software.amazon.awssdk.services.s3.S3Configuration +import java.time.Duration + +@Configuration +@Profile("dev", "local", "test") +class S3DevConfig( + @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 +) { + private fun staticCreds() = + StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)) + + @Bean + fun s3Presigner(): S3Presigner = + S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(staticCreds()) + .build() + + @Bean + fun s3Client(): S3Client { + val overrides = ClientOverrideConfiguration.builder() + .apiCallTimeout(Duration.ofSeconds(30)) + .apiCallAttemptTimeout(Duration.ofSeconds(20)) + .retryPolicy(RetryPolicy.builder().numRetries(3).build()) + .build() + + val s3cfg = S3Configuration.builder() + .checksumValidationEnabled(true) + .pathStyleAccessEnabled(false) + .build() + + val builder = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(staticCreds()) + .httpClient(UrlConnectionHttpClient.builder().build()) + .overrideConfiguration(overrides) + .serviceConfiguration(s3cfg) + + return builder.build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt b/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt new file mode 100644 index 0000000..32561ad --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt @@ -0,0 +1,51 @@ +package onku.backend.global.s3.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration +import software.amazon.awssdk.core.retry.RetryPolicy +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.S3Configuration +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import java.time.Duration + +@Configuration +@Profile("prod") +class S3ProdConfig( + @Value("\${cloud.aws.region.static}") private val region: String +) { + @Bean + fun s3Presigner(): S3Presigner = + S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build() + + @Bean + fun s3Client(): S3Client { + val overrides = ClientOverrideConfiguration.builder() + .apiCallTimeout(Duration.ofSeconds(30)) + .apiCallAttemptTimeout(Duration.ofSeconds(20)) + .retryPolicy(RetryPolicy.builder().numRetries(3).build()) + .build() + + val s3cfg = S3Configuration.builder() + .checksumValidationEnabled(true) + .pathStyleAccessEnabled(false) + .build() + + val builder = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .httpClient(UrlConnectionHttpClient.builder().build()) + .overrideConfiguration(overrides) + .serviceConfiguration(s3cfg) + + return builder.build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/dto/GetPreSignedUrlDto.kt b/src/main/kotlin/onku/backend/global/s3/dto/GetPreSignedUrlDto.kt new file mode 100644 index 0000000..cb8de25 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/dto/GetPreSignedUrlDto.kt @@ -0,0 +1,5 @@ +package onku.backend.global.s3.dto + +data class GetPreSignedUrlDto ( + val preSignedUrl : String +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/dto/GetS3UrlDto.kt b/src/main/kotlin/onku/backend/global/s3/dto/GetS3UrlDto.kt new file mode 100644 index 0000000..6e80ffe --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/dto/GetS3UrlDto.kt @@ -0,0 +1,7 @@ +package onku.backend.global.s3.dto + +data class GetS3UrlDto( + val preSignedUrl: String, + val key: String, + val originalName : String +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/dto/GetUpdateAndDeleteUrlDto.kt b/src/main/kotlin/onku/backend/global/s3/dto/GetUpdateAndDeleteUrlDto.kt new file mode 100644 index 0000000..0c3f269 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/dto/GetUpdateAndDeleteUrlDto.kt @@ -0,0 +1,10 @@ +package onku.backend.global.s3.dto + +import io.swagger.v3.oas.annotations.media.Schema + +data class GetUpdateAndDeleteUrlDto( + @Schema(description = "업로드 할 파일의 url(업로드용 presignedUrl)", example = "https://S3:~") + val newUrl : String?, + @Schema(description = "오래된 파일의 url(삭제용 presignedUrl)", example = "https://S3:~") + val oldUrl : String?, +) diff --git a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt new file mode 100644 index 0000000..25fa33e --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt @@ -0,0 +1,10 @@ +package onku.backend.global.s3.enums + +enum class FolderName{ + KUPICK_APPLICATION, + KUPICK_VIEW, + ABSENCE, + SESSION, + MEMBER_PROFILE, + NOTICE +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/enums/UploadOption.kt b/src/main/kotlin/onku/backend/global/s3/enums/UploadOption.kt new file mode 100644 index 0000000..0df4296 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/enums/UploadOption.kt @@ -0,0 +1,5 @@ +package onku.backend.global.s3.enums + +enum class UploadOption { + FILE, IMAGE +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt new file mode 100644 index 0000000..07e0148 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -0,0 +1,156 @@ +package onku.backend.global.s3.service + +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode +import onku.backend.global.s3.dto.GetS3UrlDto +import onku.backend.global.s3.enums.UploadOption +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.* +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.DeleteObjectPresignRequest +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import java.net.URL +import java.time.Duration +import java.util.UUID + +@Service +class S3Service( + @Value("\${cloud.aws.s3.bucket}") + private val bucket : String, + private val s3Presigner: S3Presigner, + private val s3Client: S3Client +) { + @Transactional(readOnly = true) + fun getPostS3Url(memberId: Long, filename: String?, folderName : String, option : UploadOption): GetS3UrlDto { + if(filename.isNullOrBlank()) { + return GetS3UrlDto(preSignedUrl = "", key = "", originalName = "") + } + + val key = "$folderName/$memberId/${UUID.randomUUID()}/$filename" + val contentType = when (option) { + UploadOption.FILE -> guessFileType(filename) + UploadOption.IMAGE -> guessImageType(filename) + else -> throw IllegalArgumentException("Unsupported upload option: $option") + } + + + val putObjReq = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(contentType) + .build() + + val presignReq = PutObjectPresignRequest.builder() + .signatureDuration(DEFAULT_EXPIRE) + .putObjectRequest(putObjReq) + .build() + + val presigned = s3Presigner.presignPutObject(presignReq) + val url: URL = presigned.url() + + return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key, originalName = getFileOriginalNameFromKey(key)) + } + + @Transactional(readOnly = true) + fun getGetS3Url(memberId: Long, key: String?): GetS3UrlDto { + if(key.isNullOrBlank()) { + return GetS3UrlDto("", "", "") + } + val contentType = guessFileType(key) + + // 응답 Content-Type을 강제로 지정하고 싶으면 responseContentType 사용 + val getObjReq = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .responseContentType(contentType) + .build() + + val presignReq = GetObjectPresignRequest.builder() + .signatureDuration(DEFAULT_EXPIRE) + .getObjectRequest(getObjReq) + .build() + + val presigned = s3Presigner.presignGetObject(presignReq) + val url: URL = presigned.url() + + return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key, originalName = getFileOriginalNameFromKey(key)) + } + + @Transactional(readOnly = true) + fun getDeleteS3Url(key: String): GetS3UrlDto { + val deleteReq = DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build() + + val presignReq = DeleteObjectPresignRequest.builder() + .signatureDuration(DEFAULT_EXPIRE) + .deleteObjectRequest(deleteReq) + .build() + + val presigned = s3Presigner.presignDeleteObject(presignReq) + val url: URL = presigned.url() + + return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key, originalName = getFileOriginalNameFromKey(key)) + } + + fun deleteObjectsNow(keys: List) { + val filtered = keys.asSequence().map(String::trim).filter(String::isNotEmpty).toList() + if (filtered.isEmpty()) return + + filtered.chunked(1000).forEach { chunk -> + val delete = Delete.builder() + .quiet(true) + .objects(chunk.map { ObjectIdentifier.builder().key(it).build() }) + .build() + + val req = DeleteObjectsRequest.builder() + .bucket(bucket) + .delete(delete) + .build() + + val resp = s3Client.deleteObjects(req) + val errors = resp.errors().orEmpty() + + if (errors.any { it.code() != "NoSuchKey" }) { + throw CustomException(ErrorCode.SERVER_UNTRACKED_ERROR) + } + } + } + + private fun guessFileType(filename: String): String { + val lower = filename.lowercase() + return when { + lower.endsWith(".jpg") || lower.endsWith(".jpeg") -> "image/jpeg" + lower.endsWith(".png") -> "image/png" + lower.endsWith(".gif") -> "image/gif" + lower.endsWith(".webp") -> "image/webp" + lower.endsWith(".pdf") -> "application/pdf" + lower.endsWith(".heic") -> "image/heic" + else -> throw CustomException(ErrorCode.INVALID_FILE_EXTENSION) + } + } + + private fun guessImageType(filename: String): String { + val lower = filename.lowercase() + return when { + lower.endsWith(".jpg") || lower.endsWith(".jpeg") -> "image/jpeg" + lower.endsWith(".png") -> "image/png" + lower.endsWith(".heic") -> "image/heic" + lower.endsWith(".webp") -> "image/webp" + else -> throw CustomException(ErrorCode.INVALID_FILE_EXTENSION) + } + } + + private fun getFileOriginalNameFromKey(key : String) : String { + return key.substringAfterLast("/") + } + + companion object { + private val DEFAULT_EXPIRE: Duration = Duration.ofMinutes(10) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt b/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt new file mode 100644 index 0000000..e9e86d1 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt @@ -0,0 +1,40 @@ +package onku.backend.global.time + +import java.time.* +import java.time.temporal.TemporalAdjusters + +object TimeRangeUtil { + private val ZONE: ZoneId = ZoneId.of("Asia/Seoul") + data class MonthRange( + val startOfMonth: LocalDateTime, + val startOfNextMonth: LocalDateTime + ) + data class WeekRange( + val startOfWeek: LocalDate, + val endOfWeek: LocalDate + ) + + fun getCurrentMonthRange(zoneId: ZoneId = ZONE): MonthRange { + val now = LocalDateTime.now(zoneId) + val startOfMonth = now.toLocalDate().withDayOfMonth(1).atStartOfDay() + val startOfNextMonth = startOfMonth.plusMonths(1) + return MonthRange(startOfMonth, startOfNextMonth) + } + + fun monthRange(year: Int, month: Int, zoneId: ZoneId = ZONE): MonthRange { + val ym = YearMonth.of(year, month) + val startZ = ym.atDay(1).atStartOfDay(zoneId) + val nextStartZ = startZ.plusMonths(1) + return MonthRange(startZ.toLocalDateTime(), nextStartZ.toLocalDateTime()) + } + + fun thisWeekRange(today: LocalDate = LocalDate.now(ZONE)) : WeekRange { + val startOfWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) + val endOfWeek = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) + return WeekRange(startOfWeek, endOfWeek) + } + + fun todayDate() : LocalDate { + return LocalDate.now(ZONE) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/util/TokenGenerator.kt b/src/main/kotlin/onku/backend/global/util/TokenGenerator.kt new file mode 100644 index 0000000..94c27a9 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/util/TokenGenerator.kt @@ -0,0 +1,15 @@ +package onku.backend.global.util + +import org.springframework.stereotype.Component +import java.security.SecureRandom +import java.util.Base64 + +@Component +class TokenGenerator { + private val random = SecureRandom() + fun generateOpaqueToken(bytes: Int = 32): String { + val buf = ByteArray(bytes) + random.nextBytes(buf) + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf) + } +} diff --git a/src/main/resources/lua/consume_token.lua b/src/main/resources/lua/consume_token.lua new file mode 100644 index 0000000..26c61b7 --- /dev/null +++ b/src/main/resources/lua/consume_token.lua @@ -0,0 +1,39 @@ +-- KEYS[1] = tokenKey ("attendance:token:{token}") +-- ARGV[1] = usedGraceSeconds (e.g., "30") + +local tokenKey = KEYS[1] +local capMs = (tonumber(ARGV[1]) or 30) * 1000 + +local function parse_payload(v) + if not v then return nil end + local parts = {} + for p in string.gmatch(v, '([^|]+)') do table.insert(parts, p) end + if #parts ~= 4 then return nil end + return parts +end + +local function adjust_ttl(key, ttlms, cap) + if ttlms <= 0 or ttlms > cap then + redis.call('PEXPIRE', key, cap) + else + redis.call('PEXPIRE', key, ttlms) + end +end + +local v = redis.call('GET', tokenKey) +if not v then return nil end + +local parts = parse_payload(v) +if not parts then return nil end + +local ttlms = redis.call('PTTL', tokenKey) + +if parts[4] ~= '1' then + parts[4] = '1' + local updated = table.concat(parts, '|') + redis.call('SET', tokenKey, updated) +end + +adjust_ttl(tokenKey, ttlms, capMs) + +return v diff --git a/src/main/resources/lua/issue_token.lua b/src/main/resources/lua/issue_token.lua new file mode 100644 index 0000000..709b402 --- /dev/null +++ b/src/main/resources/lua/issue_token.lua @@ -0,0 +1,58 @@ +-- KEYS: +-- [1] memberKey : "attendance:member:{memberId}:active" +-- [2] tokenPrefix : "attendance:token:" +-- [3] newTokenKey : "attendance:token:{newToken}" +-- +-- ARGV: +-- [1] payload : "memberId|issuedMs|expMs|usedFlag" +-- [2] ttlSeconds : new token TTL (seconds) +-- [3] newToken : new token string +-- [4] usedGraceSeconds : optional, default 30 (seconds) + +local memberKey = KEYS[1] +local tokenPrefix = KEYS[2] +local newTokenKey = KEYS[3] +local payload = ARGV[1] +local ttlSeconds = tonumber(ARGV[2]) +local newToken = ARGV[3] +local usedGraceSeconds = tonumber(ARGV[4]) or 30 +local capMs = usedGraceSeconds * 1000 + +local function parse_payload(v) + if not v then return nil end + local parts = {} + for p in string.gmatch(v, '([^|]+)') do table.insert(parts, p) end + if #parts ~= 4 then return nil end + return parts +end + +local function adjust_ttl(key, ttlms, cap) + if ttlms <= 0 or ttlms > cap then + redis.call('PEXPIRE', key, cap) + else + redis.call('PEXPIRE', key, ttlms) + end +end + +local oldToken = redis.call('GET', memberKey) +if oldToken then + local oldTokenKey = tokenPrefix .. oldToken + local oldVal = redis.call('GET', oldTokenKey) + if oldVal then + local parts = parse_payload(oldVal) + if parts then + local ttlms = redis.call('PTTL', oldTokenKey) + if parts[4] ~= '1' then + parts[4] = '1' + local updatedVal = table.concat(parts, '|') + redis.call('SET', oldTokenKey, updatedVal) + end + adjust_ttl(oldTokenKey, ttlms, capMs) + end + end +end + +redis.call('SET', newTokenKey, payload, 'EX', ttlSeconds) +redis.call('SET', memberKey, newToken, 'EX', ttlSeconds) + +return 'OK' diff --git a/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt b/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt new file mode 100644 index 0000000..653fe8a --- /dev/null +++ b/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt @@ -0,0 +1,161 @@ +package onku.backend.absence.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import onku.backend.domain.absence.AbsenceReport +import onku.backend.domain.absence.AbsenceReportErrorCode +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.absence.enums.AbsenceSubmitType +import onku.backend.domain.absence.repository.AbsenceReportRepository +import onku.backend.domain.absence.repository.projection.GetMyAbsenceReportView +import onku.backend.domain.absence.service.AbsenceService +import onku.backend.domain.member.Member +import onku.backend.domain.session.Session +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import java.time.LocalDateTime +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class AbsenceServiceTest { + + @MockK + lateinit var absenceReportRepository: AbsenceReportRepository + + lateinit var absenceService: AbsenceService + + @BeforeEach + fun setUp() { + absenceService = AbsenceService(absenceReportRepository) + } + + private val member = mockk(relaxed = true) + private val session = mockk(relaxed = true) + private val fileKey = "test-file-key" + + // 새로운 결석신고 생성 테스트 + @Test + fun `submitAbsenceReport - id가 없으면 새로 생성해서 save 한다`() { + // given + val request = SubmitAbsenceReportRequest( + absenceReportId = null, + sessionId = 1L, + submitType = AbsenceSubmitType.ABSENT, + reason = "테스트 이유", + fileName = fileKey, + null, + null + ) + + mockkObject(AbsenceReport) + val createdReport = mockk() + every { + AbsenceReport.createAbsenceReport( + member = member, + session = session, + submitAbsenceReportRequest = request, + fileKey = fileKey + ) + } returns createdReport + + every { absenceReportRepository.save(createdReport) } returns createdReport + + // when + absenceService.submitAbsenceReport(member, request, fileKey, session) + + // then + verify(exactly = 1) { + AbsenceReport.createAbsenceReport(member, session, request, fileKey) + } + verify(exactly = 1) { absenceReportRepository.save(createdReport) } + unmockkObject(AbsenceReport) + } + + + // 기존 결석신고 수정 테스트 + + @Test + fun `submitAbsenceReport - id가 있으면 기존 엔티티 update 후 save 한다`() { + // given + val request = SubmitAbsenceReportRequest( + absenceReportId = 1L, + sessionId = 1L, + submitType = AbsenceSubmitType.ABSENT, + reason = "테스트 이유", + fileName = fileKey, + null, + null + ) + + val existingReport = mockk(relaxed = true) + + every { absenceReportRepository.findById(1L) } returns java.util.Optional.of(existingReport) + every { absenceReportRepository.save(existingReport) } returns existingReport + + // when + absenceService.submitAbsenceReport(member, request, fileKey, session) + + // then + verify(exactly = 1) { + existingReport.updateAbsenceReport(request, fileKey, session) + } + verify(exactly = 1) { absenceReportRepository.save(existingReport) } + } + + + // getMyAbsenceReports 매핑 테스트 + @Test + fun `getMyAbsenceReports - projection 리스트를 응답 DTO로 잘 매핑한다`() { + val projection = mockk() + every { projection.getAbsenceReportId() } returns 10L + every { projection.getAbsenceSubmitType() } returns AbsenceSubmitType.LATE + every { projection.getAbsenceReportApproval() } returns AbsenceReportApproval.APPROVED + val now = LocalDateTime.now() + every { projection.getSubmitDateTime() } returns now + every { projection.getSessionTitle() } returns "테스트 세션" + every { projection.getSessionStartDateTime() } returns now.plusDays(1).toLocalDate() + + every { absenceReportRepository.findMyAbsenceReports(member) } returns listOf(projection) + + // when + val result = absenceService.getMyAbsenceReports(member) + + // then + assertEquals(1, result.size) + val dto = result[0] + assertEquals(10L, dto.absenceReportId) + assertEquals(AbsenceSubmitType.LATE, dto.absenceType) + assertEquals(AbsenceReportApproval.APPROVED, dto.absenceReportApproval) + assertEquals("테스트 세션", dto.sessionTitle) + } + + + // getById 성공 케이스 + @Test + fun `getById - 존재하면 엔티티를 리턴한다`() { + val report = mockk() + every { absenceReportRepository.findById(1L) } returns java.util.Optional.of(report) + + val result = absenceService.getById(1L) + + assertSame(report, result) + } + + + // getById 실패 케이스 + @Test + fun `getById - 없으면 CustomException을 던진다`() { + every { absenceReportRepository.findById(999L) } returns java.util.Optional.empty() + + val ex = assertThrows { + absenceService.getById(999L) + } + assertEquals(AbsenceReportErrorCode.ABSENCE_REPORT_NOT_FOUND, ex.errorCode) + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt b/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt new file mode 100644 index 0000000..2fe16f9 --- /dev/null +++ b/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt @@ -0,0 +1,116 @@ +package onku.backend.attendance.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import jakarta.persistence.EntityManager +import onku.backend.domain.absence.repository.AbsenceReportRepository +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.attendance.service.AttendanceFinalizeService +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.util.SessionTimeUtil +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import java.time.Clock +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class AttendanceFinalizeServiceTest { + + @MockK + lateinit var sessionRepository: SessionRepository + + @MockK + lateinit var attendanceRepository: AttendanceRepository + + @MockK + lateinit var absenceReportRepository: AbsenceReportRepository + + @MockK + lateinit var memberRepository: MemberRepository + + @MockK + lateinit var memberPointHistoryRepository: MemberPointHistoryRepository + + @MockK + lateinit var em: EntityManager + + lateinit var clock: Clock + + lateinit var attendanceFinalizeService: AttendanceFinalizeService + + @BeforeEach + fun setUp() { + clock = Clock.fixed( + Instant.parse("2025-01-01T09:00:00Z"), + ZoneId.of("Asia/Seoul") + ) + + attendanceFinalizeService = AttendanceFinalizeService( + sessionRepository = sessionRepository, + attendanceRepository = attendanceRepository, + absenceReportRepository = absenceReportRepository, + memberRepository = memberRepository, + memberPointHistoryRepository = memberPointHistoryRepository, + em = em, + clock = clock + ) + + mockkStatic(SessionTimeUtil::class) + every { SessionTimeUtil.startDateTime(any()) } returns LocalDateTime.of(2025, 1, 1, 10, 0) + } + + @AfterEach + fun afterTest() { + unmockkStatic(SessionTimeUtil::class) + } + + private fun createSession( + id: Long = 1L, + week: Long = 1L, + finalized: Boolean = false + ): Session { + val session = mockk(relaxed = true) + every { session.id } returns id + every { session.week } returns week + every { session.attendanceFinalized } returns finalized + every { session.attendanceFinalized = any() } just Runs + every { session.attendanceFinalizedAt = any() } just Runs + return session + } + + @Test + fun `finalizeSession - 이미 final 되었다면 바로 return`() { + val session = createSession(finalized = true) + every { sessionRepository.findById(1L) } returns java.util.Optional.of(session) + + attendanceFinalizeService.finalizeSession(1L) + + verify(exactly = 0) { memberRepository.findApprovedMemberIds() } + verify(exactly = 0) { attendanceRepository.findMemberIdsBySessionId(any()) } + verify(exactly = 0) { absenceReportRepository.findReportsBySessionAndMembers(any(), any()) } + } + + @Test + fun `finalizeSession - approved member가 없으면 finalize만 한다`() { + val session = createSession(finalized = false) + + every { sessionRepository.findById(1L) } returns java.util.Optional.of(session) + every { memberRepository.findApprovedMemberIds() } returns emptyList() + + attendanceFinalizeService.finalizeSession(1L) + + verify { session.attendanceFinalized = true } + verify { session.attendanceFinalizedAt = LocalDateTime.now(clock) } + verify(exactly = 0) { attendanceRepository.findMemberIdsBySessionId(any()) } + verify(exactly = 0) { absenceReportRepository.findReportsBySessionAndMembers(any(), any()) } + } + +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt b/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt new file mode 100644 index 0000000..94d37c6 --- /dev/null +++ b/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt @@ -0,0 +1,272 @@ +package onku.backend.attendance.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import jakarta.persistence.EntityManager +import onku.backend.domain.attendance.AttendanceErrorCode +import onku.backend.domain.attendance.AttendancePolicy +import onku.backend.domain.attendance.dto.AttendanceAvailabilityResponse +import onku.backend.domain.attendance.dto.AttendanceTokenCore +import onku.backend.domain.attendance.enums.AttendanceAvailabilityReason +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.attendance.service.AttendanceService +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.util.SessionTimeUtil +import onku.backend.global.exception.CustomException +import onku.backend.global.redis.cache.AttendanceTokenCache +import onku.backend.global.redis.dto.TokenData +import onku.backend.global.util.TokenGenerator +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import java.time.Clock +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class AttendanceServiceTest { + @MockK(relaxUnitFun = true) lateinit var tokenCache: AttendanceTokenCache + @MockK lateinit var sessionRepository: SessionRepository + @MockK lateinit var attendanceRepository: AttendanceRepository + @MockK lateinit var memberProfileRepository: MemberProfileRepository + @MockK lateinit var memberPointHistoryRepository: MemberPointHistoryRepository + @MockK lateinit var tokenGenerator: TokenGenerator + @MockK lateinit var em: EntityManager + + + lateinit var clock: Clock + lateinit var attendanceService: AttendanceService + + @BeforeEach + fun setUp() { + clock = Clock.fixed( + Instant.parse("2025-01-01T01:00:00Z"), + ZoneId.of("Asia/Seoul") + ) + + attendanceService = AttendanceService( + tokenCache = tokenCache, + sessionRepository = sessionRepository, + attendanceRepository = attendanceRepository, + memberProfileRepository = memberProfileRepository, + memberPointHistoryRepository = memberPointHistoryRepository, + tokenGenerator = tokenGenerator, + em = em, + clock = clock + ) + + mockkStatic(SessionTimeUtil::class) + } + + private fun createMember(id: Long = 1L): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + return m + } + + private fun createSession( + id: Long = 10L, + week: Long = 3L, + isHoliday: Boolean = false + ): Session { + val s = mockk(relaxed = true) + every { s.id } returns id + every { s.week } returns week + every { s.isHoliday } returns isHoliday + return s + } + + private fun stubMemberProfile(memberId: Long, name: String = "테스트유저") { + val profile = mockk() + every { profile.name } returns name + every { memberProfileRepository.findById(memberId) } returns Optional.of(profile) + } + + // issueAttendanceTokenFor + @Test + fun `issueAttendanceTokenFor - 토큰 발급 및 캐시에 저장`() { + val member = createMember(1L) + val now = LocalDateTime.now(clock) + val ttl = AttendancePolicy.TOKEN_TTL_SECONDS + val fakeToken = "opaque-token-123" + + every { tokenGenerator.generateOpaqueToken() } returns fakeToken + + stubMemberProfile(member.id!!) + + val result: AttendanceTokenCore = attendanceService.issueAttendanceTokenFor(member) + + assertEquals(fakeToken, result.token) + assertEquals(now.plusSeconds(ttl), result.expAt) + + verify { + tokenCache.putAsActiveSingle( + 1L, + "opaque-token-123", + now, + now.plusSeconds(ttl), + ttl, + null + ) + } + } + + // scanAndRecordBy - 정상 출석 기록 + @Test + fun `scanAndRecordBy - 정상 출석 기록 및 포인트 적립`() { + val admin = createMember(id = 99L) + val member = createMember(id = 1L) + val token = "token-abc" + + val now = LocalDateTime.now(clock) + val session = createSession(id = 10L, week = 2L, isHoliday = false) + + every { + sessionRepository.findOpenWindow(any(), now) + } returns listOf(session) + + val peekResult = TokenData( + memberId = member.id!!, + issuedAt = now.minusSeconds(10), + expAt = now.plusSeconds(100), + used = false + ) + + // tokenCache.peek 는 이 TokenData 리턴 + every { tokenCache.peek(token) } returns peekResult + + // 아직 출석 안 되어 있음 + every { + attendanceRepository.existsBySessionIdAndMemberId(any(), any()) + } returns false + + // 토큰 소비 시에도 같은 TokenData 리턴 + every { tokenCache.consumeToken(token, any()) } returns peekResult + + // SessionTimeUtil.startDateTime(session) mock + mockkObject(SessionTimeUtil) + every { SessionTimeUtil.startDateTime(session) } returns now.plusMinutes(5) + + + // insertOnly: 어떤 값이 오든 1 리턴 + every { + attendanceRepository.insertOnly( + any(), any(), any(), any(), any(), any() + ) + } returns 1 + + // getReference: MemberProxy 역할만 하면 되니까 any()로 + val memberRef = mockk() + every { em.getReference(Member::class.java, any()) } returns memberRef + + every { memberPointHistoryRepository.save(any()) } answers { firstArg() } + + // 이번 주 요약은 countGroupedByStatusBetweenDates 를 빈 리스트로만 처리 + every { + attendanceRepository.countGroupedByStatusBetweenDates(any(), any()) + } returns emptyList() + + // 프로필 이름 stub + stubMemberProfile(member.id!!) + + // when + val response = attendanceService.scanAndRecordBy(admin, token) + + // then + assertEquals(member.id!!, response.memberId) + assertEquals(session.id!!, response.sessionId) + assertEquals(AttendancePointType.PRESENT, response.state) + assertEquals(now, response.scannedAt) + + verify(exactly = 1) { + attendanceRepository.insertOnly( + any(), // sessionId + any(), // memberId + AttendancePointType.PRESENT.name, + any(), any(), any() + ) + } + + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + } + + // scanAndRecordBy - 세션 안 열렸을 때 예외 + @Test + fun `scanAndRecordBy - 열려있는 세션이 없으면 예외`() { + val admin = createMember(99L) + val token = "token-x" + val now = LocalDateTime.now(clock) + + every { sessionRepository.findOpenWindow(any(), now) } returns emptyList() + + val ex = assertThrows { + attendanceService.scanAndRecordBy(admin, token) + } + assertEquals(AttendanceErrorCode.SESSION_NOT_OPEN, ex.errorCode) + } + + + // checkAvailabilityFor + + @Test + fun `checkAvailabilityFor - 세션 없으면 available false`() { + val member = createMember(1L) + val now = LocalDateTime.now(clock) + + every { sessionRepository.findOpenWindow(any(), now) } returns emptyList() + + val result: AttendanceAvailabilityResponse = + attendanceService.checkAvailabilityFor(member) + + assertEquals(false, result.available) + assertEquals(AttendanceAvailabilityReason.NO_OPEN_SESSION, result.reason) + } + + @Test + fun `checkAvailabilityFor - 이미 출석했으면 available false`() { + val member = createMember(1L) + val session = createSession(id = 10L) + + // 세션 하나 열려있다고 가정 + every { + sessionRepository.findOpenWindow(any(), any()) + } returns listOf(session) + + // 해당 세션에 이미 출석 기록 있음 + every { + attendanceRepository.existsBySessionIdAndMemberId(any(), any()) + } returns true + + val result = attendanceService.checkAvailabilityFor(member) + + assertEquals(false, result.available) + assertEquals(AttendanceAvailabilityReason.ALREADY_RECORDED, result.reason) + } + + @Test + fun `checkAvailabilityFor - 출석 가능`() { + val member = createMember(1L) + val now = LocalDateTime.now(clock) + val session = createSession(id = 10L) + + every { sessionRepository.findOpenWindow(any(), now) } returns listOf(session) + every { attendanceRepository.existsBySessionIdAndMemberId(any(), any()) } returns false + + val result = attendanceService.checkAvailabilityFor(member) + + assertTrue(result.available) + assertEquals(null, result.reason) + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/crypto/CryptoErrorTest.kt b/src/test/kotlin/onku/backend/crypto/CryptoErrorTest.kt new file mode 100644 index 0000000..aa8086e --- /dev/null +++ b/src/test/kotlin/onku/backend/crypto/CryptoErrorTest.kt @@ -0,0 +1,46 @@ +package onku.backend.crypto + +import onku.backend.global.crypto.AESService +import onku.backend.global.crypto.exception.DecryptionException +import org.junit.jupiter.api.Assertions.assertThrows +import java.util.* +import kotlin.test.Test + +class CryptoErrorTest { + private fun keyA(): String = + Base64.getEncoder().encodeToString(ByteArray(32) { 1 }) + + private fun keyB(): String = + Base64.getEncoder().encodeToString(ByteArray(32) { 2 }) + + @Test + fun `decrypt - invalid base64 throws DecryptionException`() { + val aes = AESService(keyA()) + + assertThrows(DecryptionException::class.java) { + aes.decrypt("%%%not-base64%%%") + } + } + + @Test + fun `decrypt - too short ciphertext throws DecryptionException`() { + val aes = AESService(keyA()) + + val tooShort = Base64.getEncoder().encodeToString(byteArrayOf(0x01)) + assertThrows(DecryptionException::class.java) { + aes.decrypt(tooShort) + } + } + + @Test + fun `decrypt - wrong key throws DecryptionException`() { + val encryptor = AESService(keyA()) + val decryptor = AESService(keyB()) + + val cipher = encryptor.encrypt("hello")!! + + assertThrows(DecryptionException::class.java) { + decryptor.decrypt(cipher) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/crypto/CryptoJpaErrorTest.kt b/src/test/kotlin/onku/backend/crypto/CryptoJpaErrorTest.kt new file mode 100644 index 0000000..3ca9e93 --- /dev/null +++ b/src/test/kotlin/onku/backend/crypto/CryptoJpaErrorTest.kt @@ -0,0 +1,63 @@ +package onku.backend.crypto + + +import onku.backend.domain.absence.enums.AbsenceApprovedType +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Role +import onku.backend.domain.member.enums.SocialType +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.global.crypto.exception.DecryptionException +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.orm.jpa.JpaSystemException +import org.springframework.test.context.ActiveProfiles +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import kotlin.test.Test + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class CryptoJpaErrorTest( + @Autowired private val jdbcTemplate: JdbcTemplate, + @Autowired private val memberRepository: MemberRepository, +) { + + @Test + fun `jpa decrypt error is wrapped as JpaSystemException`() { + // ✅ 1) DB에 "깨진 암호문"을 직접 넣기 (Base64 아님) + jdbcTemplate.update( + "insert into MEMBER (member_id, email, role, social_type, social_id, has_info, approval, is_tf, is_staff, created_at, updated_at) values (?,?,?,?,?,?,?,?,?,?,?)", + 1L, + "abc@def.com", + Role.USER.name, + SocialType.KAKAO.name, + "KAKAO", + false, + ApprovalStatus.APPROVED.name, + false, + false, + LocalDateTime.now(), + LocalDateTime.now() + ) + jdbcTemplate.update( + "insert into MEMBER_PROFILE (member_id, name) values (?, ?)", + 1L, + "%%%not-base64%%%" + ) + + // ✅ 2) 조회 시 Converter decrypt 실행 -> 예외 -> JPA 래핑 + val ex = assertThrows(JpaSystemException::class.java) { + memberRepository.findById(1L).orElseThrow() + } + + // ✅ 3) 원인 체인에 DecryptionException이 있는지 확인 + assertTrue(ex.hasCause(DecryptionException::class.java)) + } + + private fun Throwable.hasCause(type: Class<*>): Boolean = + generateSequence(this) { it.cause }.any { type.isInstance(it) } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt b/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt new file mode 100644 index 0000000..705a21f --- /dev/null +++ b/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt @@ -0,0 +1,296 @@ +package onku.backend.kupick.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import onku.backend.domain.kupick.Kupick +import onku.backend.domain.kupick.KupickErrorCode +import onku.backend.domain.kupick.dto.KupickMemberInfo +import onku.backend.domain.kupick.repository.KupickRepository +import onku.backend.domain.kupick.repository.projection.KupickUrls +import onku.backend.domain.kupick.repository.projection.KupickWithProfile +import onku.backend.domain.kupick.service.KupickService +import onku.backend.domain.member.Member +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@ExtendWith(MockKExtension::class) +class KupickServiceTest { + + @MockK + lateinit var kupickRepository: KupickRepository + + lateinit var kupickService: KupickService + + @BeforeEach + fun setUp() { + kupickService = KupickService(kupickRepository) + } + + private fun createMember(id: Long = 1L): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + return m + } + + // ========================== + // submitApplication + // ========================== + + @Test + fun `submitApplication - 기존 신청이 없으면 새로 생성하고 null 반환`() { + val member = createMember(1L) + val newUrl = "s3://kupick/apply/new.png" + + every { + kupickRepository.findFirstByMemberAndApplicationDateBetween( + member, + any(), // startOfMonth + any() // startOfNextMonth + ) + } returns null + + every { + kupickRepository.save(any()) + } returns mockk(relaxed = true) + + val result = kupickService.submitApplication(member, newUrl) + + assertNull(result) + + verify(exactly = 1) { + kupickRepository.findFirstByMemberAndApplicationDateBetween(member, any(), any()) + } + verify(exactly = 1) { + kupickRepository.save(any()) + } + } + + @Test + fun `submitApplication - 기존 신청 있고 미승인이면 기존 URL 반환하고 updateApplication 호출`() { + val member = createMember(1L) + val oldUrl = "s3://kupick/apply/old.png" + val newUrl = "s3://kupick/apply/new.png" + + val existing = mockk(relaxed = true) + every { existing.approval } returns false + every { existing.applicationImageUrl } returns oldUrl + every { existing.updateApplication(newUrl, any()) } just Runs + + every { + kupickRepository.findFirstByMemberAndApplicationDateBetween(member, any(), any()) + } returns existing + + val result = kupickService.submitApplication(member, newUrl) + + assertEquals(oldUrl, result) + + verify(exactly = 1) { + kupickRepository.findFirstByMemberAndApplicationDateBetween(member, any(), any()) + } + verify(exactly = 1) { + existing.updateApplication(newUrl, any()) + } + verify(exactly = 0) { kupickRepository.save(any()) } + } + + @Test + fun `submitApplication - 기존 신청이 승인 상태면 KUPICK_NOT_UPDATE 예외`() { + val member = createMember(1L) + val newUrl = "s3://kupick/apply/new.png" + + val existing = mockk(relaxed = true) + every { existing.approval } returns true + + every { + kupickRepository.findFirstByMemberAndApplicationDateBetween(member, any(), any()) + } returns existing + + val ex = assertFailsWith { + kupickService.submitApplication(member, newUrl) + } + + assertEquals(KupickErrorCode.KUPICK_NOT_UPDATE, ex.errorCode) + + verify(exactly = 1) { + kupickRepository.findFirstByMemberAndApplicationDateBetween(member, any(), any()) + } + verify(exactly = 0) { kupickRepository.save(any()) } + } + + // ========================== + // submitView + // ========================== + + @Test + fun `submitView - 이번달 신청이 없으면 KUPICK_APPLICATION_FIRST 예외`() { + val member = createMember(1L) + val viewUrl = "s3://kupick/view/new.png" + + every { + kupickRepository.findThisMonthByMember(member, any(), any()) + } returns null + + val ex = assertFailsWith { + kupickService.submitView(member, viewUrl) + } + + assertEquals(KupickErrorCode.KUPICK_APPLICATION_FIRST, ex.errorCode) + verify { kupickRepository.findThisMonthByMember(member, any(), any()) } + } + + @Test + fun `submitView - 이미 승인된 큐픽이면 KUPICK_NOT_UPDATE 예외`() { + val member = createMember(1L) + val viewUrl = "s3://kupick/view/new.png" + + val existing = mockk(relaxed = true) + every { existing.approval } returns true + + every { + kupickRepository.findThisMonthByMember(member, any(), any()) + } returns existing + + val ex = assertFailsWith { + kupickService.submitView(member, viewUrl) + } + + assertEquals(KupickErrorCode.KUPICK_NOT_UPDATE, ex.errorCode) + + verify { kupickRepository.findThisMonthByMember(member, any(), any()) } + verify(exactly = 0) { existing.submitView(any(), any()) } + } + + @Test + fun `submitView - 정상 제출이면 이전 viewUrl 반환하고 submitView 호출`() { + val member = createMember(1L) + val oldViewUrl = "s3://kupick/view/old.png" + val newViewUrl = "s3://kupick/view/new.png" + + val existing = mockk(relaxed = true) + every { existing.approval } returns false + every { existing.viewImageUrl } returns oldViewUrl + every { existing.submitView(newViewUrl, any()) } just Runs + + every { + kupickRepository.findThisMonthByMember(member, any(), any()) + } returns existing + + val result = kupickService.submitView(member, newViewUrl) + + assertEquals(oldViewUrl, result) + + verify { kupickRepository.findThisMonthByMember(member, any(), any()) } + verify { existing.submitView(newViewUrl, any()) } + } + + // ========================== + // viewMyKupick + // ========================== + + @Test + fun `viewMyKupick - 이번달 조회 결과가 있으면 그대로 반환`() { + val member = createMember(1L) + val urls = mockk() + + every { + kupickRepository.findUrlsForMemberInMonth(member, any(), any()) + } returns urls + + val result = kupickService.viewMyKupick(member) + + assertSame(urls, result) + verify { + kupickRepository.findUrlsForMemberInMonth(member, any(), any()) + } + } + + @Test + fun `viewMyKupick - 이번달 조회 결과가 없으면 null`() { + val member = createMember(1L) + + every { + kupickRepository.findUrlsForMemberInMonth(member, any(), any()) + } returns null + + val result = kupickService.viewMyKupick(member) + + assertNull(result) + verify { + kupickRepository.findUrlsForMemberInMonth(member, any(), any()) + } + } + + // ========================== + // findAllAsShowUpdateResponse + // ========================== + + @Test + fun `findAllAsShowUpdateResponse - year, month에 대한 결과 리스트 그대로 반환`() { + val year = 2025 + val month = 9 + + val row1 = mockk() + val row2 = mockk() + + every { + kupickRepository.findAllWithProfile(any(), any()) + } returns listOf(row1, row2) + + val result = kupickService.findAllAsShowUpdateResponse(year, month) + + assertEquals(2, result.size) + assertSame(row1, result[0]) + assertSame(row2, result[1]) + + verify { kupickRepository.findAllWithProfile(any(), any()) } + } + + // ========================== + // decideApproval + // ========================== + + @Test + fun `decideApproval - 존재하지 않으면 KUPICK_NOT_FOUND 예외`() { + every { kupickRepository.findById(1L) } returns java.util.Optional.empty() + + val ex = assertFailsWith { + kupickService.decideApproval(1L, true) + } + + assertEquals(KupickErrorCode.KUPICK_NOT_FOUND, ex.errorCode) + } + + @Test + fun `decideApproval - 존재하면 updateApproval 호출`() { + val kupick = mockk(relaxed = true) + every { kupickRepository.findById(1L) } returns java.util.Optional.of(kupick) + every { kupick.updateApproval(true) } just Runs + + kupickService.decideApproval(1L, true) + + verify { kupickRepository.findById(1L) } + verify { kupick.updateApproval(true) } + } + + // ========================== + // findFcmInfo + // ========================== + + @Test + fun `findFcmInfo - 레포에서 조회한 KupickMemberInfo를 그대로 반환`() { + val info = mockk() + + every { kupickRepository.findFcmInfoByKupickId(10L) } returns info + + val result = kupickService.findFcmInfo(10L) + + assertSame(info, result) + verify { kupickRepository.findFcmInfoByKupickId(10L) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/member/service/MemberAlarmHistoryServiceTest.kt b/src/test/kotlin/onku/backend/member/service/MemberAlarmHistoryServiceTest.kt new file mode 100644 index 0000000..03f2121 --- /dev/null +++ b/src/test/kotlin/onku/backend/member/service/MemberAlarmHistoryServiceTest.kt @@ -0,0 +1,108 @@ +package onku.backend.member.service + +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberAlarmHistory +import onku.backend.domain.member.dto.MemberAlarmHistoryItemResponse +import onku.backend.domain.member.repository.MemberAlarmHistoryRepository +import onku.backend.domain.member.service.MemberAlarmHistoryService +import onku.backend.global.alarm.enums.AlarmEmojiType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import java.time.LocalDateTime +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class MemberAlarmHistoryServiceTest { + + @MockK lateinit var memberAlarmHistoryRepository: MemberAlarmHistoryRepository + + lateinit var service: MemberAlarmHistoryService + + @BeforeEach + fun setUp() { + service = MemberAlarmHistoryService(memberAlarmHistoryRepository) + } + + private fun createMember(id: Long = 1L): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + return m + } + + // getMyAlarms 테스트 + @Test + fun `getMyAlarms - 히스토리를 최신순으로 DTO로 변환해서 반환한다`() { + // given + val member = createMember(1L) + + val createdAt1 = LocalDateTime.of(2025, 1, 1, 10, 0) + val createdAt2 = LocalDateTime.of(2025, 1, 2, 9, 30) + + // 엔티티는 mock으로 충분 (필드 몇 개만 쓰니까) + val history1 = mockk() + every { history1.message } returns "첫 번째 알림" + every { history1.type } returns AlarmEmojiType.STAR + every { history1.createdAt } returns createdAt1 + + val history2 = mockk() + every { history2.message } returns "두 번째 알림" + every { history2.type } returns AlarmEmojiType.WARNING + every { history2.createdAt } returns createdAt2 + + every { + memberAlarmHistoryRepository.findByMemberOrderByCreatedAtDesc(member) + } returns listOf(history2, history1) + + // when + val result: List = service.getMyAlarms(member) + + // then + assertEquals(2, result.size) + + assertEquals("두 번째 알림", result[0].message) + assertEquals(AlarmEmojiType.WARNING, result[0].type) + assertEquals("01/02 09:30", result[0].createdAt) + + assertEquals("첫 번째 알림", result[1].message) + assertEquals(AlarmEmojiType.STAR, result[1].type) + assertEquals("01/01 10:00", result[1].createdAt) + + verify(exactly = 1) { + memberAlarmHistoryRepository.findByMemberOrderByCreatedAtDesc(member) + } + } + + // saveAlarm 테스트 + @Test + fun `saveAlarm - member와 타입, 메시지로 알림 히스토리를 저장한다`() { + // given + val member = createMember(1L) + val type = AlarmEmojiType.STAR + val message = "큪픽 승인 되었습니다." + + val slot = slot() + every { memberAlarmHistoryRepository.save(capture(slot)) } answers { + slot.captured + } + + // when + service.saveAlarm(member, type, message) + + // then + verify(exactly = 1) { + memberAlarmHistoryRepository.save(any()) + } + + val saved = slot.captured + assertEquals(member, saved.member) + assertEquals(message, saved.message) + assertEquals(type, saved.type) + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/member/service/MemberProfileServiceTest.kt b/src/test/kotlin/onku/backend/member/service/MemberProfileServiceTest.kt new file mode 100644 index 0000000..db2c264 --- /dev/null +++ b/src/test/kotlin/onku/backend/member/service/MemberProfileServiceTest.kt @@ -0,0 +1,444 @@ +package onku.backend.member.service + +import io.mockk.Runs +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.just +import io.mockk.mockk +import io.mockk.* +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.dto.MemberProfileBasicsResponse +import onku.backend.domain.member.dto.MemberProfileUpdateRequest +import onku.backend.domain.member.dto.MembersPagedResponse +import onku.backend.domain.member.dto.OnboardingRequest +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Part +import onku.backend.domain.member.enums.Role +import onku.backend.domain.member.enums.SocialType +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.member.service.MemberProfileService +import onku.backend.domain.member.service.MemberService +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.s3.dto.GetS3UrlDto +import onku.backend.global.s3.dto.GetUpdateAndDeleteUrlDto +import onku.backend.global.s3.enums.FolderName +import onku.backend.global.s3.enums.UploadOption +import onku.backend.global.s3.service.S3Service +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import java.util.* +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@ExtendWith(MockKExtension::class) +class MemberProfileServiceTest { + + @MockK lateinit var memberProfileRepository: MemberProfileRepository + @MockK lateinit var memberRepository: MemberRepository + @MockK lateinit var memberService: MemberService + @MockK lateinit var memberPointHistoryRepository: MemberPointHistoryRepository + @MockK lateinit var s3Service: S3Service + + lateinit var service: MemberProfileService + + @BeforeEach + fun setUp() { + service = MemberProfileService( + memberProfileRepository = memberProfileRepository, + memberRepository = memberRepository, + memberService = memberService, + memberPointHistoryRepository = memberPointHistoryRepository, + s3Service = s3Service + ) + } + + private fun createMember( + id: Long = 1L, + hasInfo: Boolean = false, + approval: ApprovalStatus = ApprovalStatus.PENDING, + isStaff: Boolean = false, + role: Role = Role.GUEST, + socialType: SocialType = SocialType.KAKAO + ): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + every { m.hasInfo } returns hasInfo + every { m.approval } returns approval + every { m.isStaff } returns isStaff + every { m.role } returns role + every { m.socialType } returns socialType + every { m.email } returns "user$id@test.com" + every { m.updateFcmToken(any()) } just Runs + return m + } + + private fun createProfile( + member: Member, + name: String? = "홍길동", + part: Part = Part.BACKEND, + school: String? = "K대", + major: String? = "컴공", + phone: String? = "010-0000-0000", + profileImage: String? = null + ): MemberProfile { + return MemberProfile( + member = member, + name = name, + school = school, + major = major, + part = part, + phoneNumber = phone, + profileImage = profileImage + ) + } + + // submitOnboarding + + @Test + fun `submitOnboarding - PENDING + hasInfo false면 프로필 생성하고 온보딩 처리`() { + val member = createMember(id = 1L, hasInfo = false, approval = ApprovalStatus.PENDING) + + val req = OnboardingRequest( + name = "홍길동", + school = "K대", + major = "컴공", + part = Part.BACKEND, + phoneNumber = "010-0000-0000", + fcmToken = "fcm-123" + ) + + val persistedMember = createMember(id = 1L, hasInfo = false, approval = ApprovalStatus.PENDING) + every { memberRepository.findById(1L) } returns Optional.of(persistedMember) + every { memberProfileRepository.findById(1L) } returns Optional.empty() + every { memberProfileRepository.save(any()) } answers { firstArg() } + every { memberService.markOnboarded(persistedMember) } just Runs + + val result = service.submitOnboarding(member, req) + + assertEquals(ApprovalStatus.PENDING, result.status) + + val slot = slot() + + every { memberProfileRepository.save(capture(slot)) } answers { slot.captured } + + service.submitOnboarding(member, req) + + // then + val saved = slot.captured + assertEquals("홍길동", saved.name) + assertEquals("K대", saved.school) + assertEquals("컴공", saved.major) + assertEquals(Part.BACKEND, saved.part) + assertEquals("010-0000-0000", saved.phoneNumber) + } + + @Test + fun `submitOnboarding - 이미 hasInfo true면 INVALID_MEMBER_STATE`() { + val member = createMember(id = 1L, hasInfo = true, approval = ApprovalStatus.PENDING) + + val req = OnboardingRequest( + name = "홍길동", + school = "K대", + major = "컴공", + part = Part.BACKEND, + phoneNumber = "010-0000-0000", + fcmToken = "fcm-123" + ) + + val ex = assertFailsWith { + service.submitOnboarding(member, req) + } + assertEquals(MemberErrorCode.INVALID_MEMBER_STATE, ex.errorCode) + } + + @Test + fun `submitOnboarding - approval이 PENDING이 아니면 INVALID_MEMBER_STATE`() { + val member = createMember(id = 1L, hasInfo = false, approval = ApprovalStatus.APPROVED) + + val req = OnboardingRequest( + name = "홍길동", + school = "K대", + major = "컴공", + part = Part.BACKEND, + phoneNumber = "010-0000-0000", + fcmToken = "fcm-123" + ) + + val ex = assertFailsWith { + service.submitOnboarding(member, req) + } + assertEquals(MemberErrorCode.INVALID_MEMBER_STATE, ex.errorCode) + } + + // getProfileSummary + + @Test + fun `getProfileSummary - 프로필과 포인트, 프로필 이미지 URL을 반환`() { + val member = createMember(id = 1L) + every { member.email } returns "user1@test.com" + + val profile = createProfile( + member = member, + name = "홍길동", + part = Part.BACKEND, + school = "K대", + major = "컴공", + phone = "010-0000-0000", + profileImage = "PROFILE/1/a.png" + ) + + every { memberProfileRepository.findById(1L) } returns Optional.of(profile) + + // sumPointsForMember projection mock + every { + memberPointHistoryRepository.sumPointsForMember(member) + } returns mockk { + every { getTotalPoints() } returns 80L + } + + every { + s3Service.getGetS3Url(1L, "PROFILE/1/a.png") + } returns GetS3UrlDto( + key = "PROFILE/1/a.png", + preSignedUrl = "https://example.com/profile1.png", + originalName = "a.png" + ) + + val resp = service.getProfileSummary(member) + + assertEquals("user1@test.com", resp.email) + assertEquals("홍길동", resp.name) + assertEquals(Part.BACKEND, resp.part) + assertEquals(80L, resp.totalPoints) + assertEquals("https://example.com/profile1.png", resp.profileImage) + } + + // issueProfileImageUploadUrl + + @Test + fun `issueProfileImageUploadUrl - 새 업로드 URL과 기존 삭제 URL을 반환`() { + val member = createMember(1L) + + // 기존 프로필: 이전 이미지 키 존재 + val profile = createProfile( + member = member, + profileImage = "PROFILE/1/old.png" + ) + every { memberProfileRepository.findById(1L) } returns Optional.of(profile) + + // 새 업로드용 pre-signed POST URL + every { + s3Service.getPostS3Url( + memberId = 1L, + filename = "new.png", + folderName = FolderName.MEMBER_PROFILE.name, + option = UploadOption.IMAGE + ) + } returns GetS3UrlDto( + key = "PROFILE/1/new.png", + preSignedUrl = "https://upload-url", + originalName = "new.png" + ) + + // 기존 삭제용 URL + every { + s3Service.getDeleteS3Url("PROFILE/1/old.png") + } returns GetS3UrlDto( + key = "PROFILE/1/old.png", + preSignedUrl = "https://delete-old", + originalName = "old.png" + ) + + val result: GetUpdateAndDeleteUrlDto = + service.issueProfileImageUploadUrl(member, "new.png") + + // newUrl, oldUrl 확인 + assertEquals("https://upload-url", result.newUrl) + assertEquals("https://delete-old", result.oldUrl) + + // submitProfileImage 안에서 profileImage 키가 바뀌었는지 + assertEquals("PROFILE/1/new.png", profile.profileImage) + + verify { + s3Service.getPostS3Url(1L, "new.png", FolderName.MEMBER_PROFILE.name, UploadOption.IMAGE) + s3Service.getDeleteS3Url("PROFILE/1/old.png") + } + } + // submitProfileImage + @Test + fun `submitProfileImage - old key를 반환하고 프로필 이미지 키를 바꾼다`() { + val member = createMember(1L) + val profile = createProfile(member = member, profileImage = "PROFILE/1/old.png") + + every { memberProfileRepository.findById(1L) } returns Optional.of(profile) + + val old = service.submitProfileImage(member, "PROFILE/1/new.png") + + assertEquals("PROFILE/1/old.png", old) + assertEquals("PROFILE/1/new.png", profile.profileImage) + } + + // updateProfile + + @Test + fun `updateProfile - 기본 정보 수정 후 basics 응답 반환`() { + val member = createMember(1L) + val profile = createProfile(member = member, name = "기존이름", profileImage = "PROFILE/1/a.png") + + every { memberRepository.findById(1L) } returns Optional.of(member) + every { memberProfileRepository.findById(1L) } returns Optional.of(profile) + + every { + s3Service.getGetS3Url(1L, "PROFILE/1/a.png") + } returns GetS3UrlDto( + key = "PROFILE/1/a.png", + preSignedUrl = "https://profile-url", + originalName = "a.png" + ) + + val req = MemberProfileUpdateRequest( + name = "새 이름", + school = "새 학교", + major = "새 전공", + part = Part.FRONTEND, + phoneNumber = "010-1111-2222" + ) + + val resp: MemberProfileBasicsResponse = service.updateProfile(1L, req) + + assertEquals("새 이름", resp.name) + assertEquals(Part.FRONTEND, resp.part) + assertEquals("새 학교", resp.school) + assertEquals("https://profile-url", resp.profileImageUrl) + + assertEquals("새 이름", profile.name) + assertEquals("새 학교", profile.school) + assertEquals("새 전공", profile.major) + assertEquals(Part.FRONTEND, profile.part) + assertEquals("010-1111-2222", profile.phoneNumber) + } + + // getApprovedMembersPagedWithCounts + + @Test + fun `getApprovedMembersPagedWithCounts - isStaff null이면 전체 승인 멤버 조회`() { + every { memberRepository.countByApproval(ApprovalStatus.PENDING) } returns 2L + every { memberRepository.countByApproval(ApprovalStatus.APPROVED) } returns 10L + every { memberRepository.countByApproval(ApprovalStatus.REJECTED) } returns 1L + + val member = createMember( + id = 1L, + isStaff = true, + approval = ApprovalStatus.APPROVED, + role = Role.USER, + socialType = SocialType.KAKAO + ) + + val profile = createProfile( + member = member, + name = "홍길동", + part = Part.BACKEND, + school = "K대", + major = "컴공", + phone = "010-0000-0000", + profileImage = "PROFILE/1/a.png" + ) + + every { + memberProfileRepository.findByMemberApproval( + ApprovalStatus.APPROVED, + any() + ) + } returns PageImpl(listOf(profile), PageRequest.of(0, 10), 1) + + every { + s3Service.getGetS3Url(1L, "PROFILE/1/a.png") + } returns GetS3UrlDto( + key = "PROFILE/1/a.png", + preSignedUrl = "https://profile-url", + originalName = "a.png" + ) + + val resp: MembersPagedResponse = + service.getApprovedMembersPagedWithCounts(page = 0, size = 10, isStaff = null) + + assertEquals(2L, resp.pendingCount) + assertEquals(10L, resp.approvedCount) + assertEquals(1L, resp.rejectedCount) + + assertEquals(1, resp.members.data.size) + val item = resp.members.data[0] + assertEquals(1L, item.memberId) + assertEquals("홍길동", item.name) + assertEquals(Part.BACKEND, item.part) + assertEquals("K대", item.school) + assertEquals("컴공", item.major) + assertEquals("010-0000-0000", item.phoneNumber) + assertTrue(item.isStaff) + assertEquals(ApprovalStatus.APPROVED, item.approval) + assertEquals("https://profile-url", item.profileImageUrl) + } + + // getApprovalRequestMembers + + @Test + fun `getApprovalRequestMembers - PENDING, REJECTED 상태 멤버 목록과 카운트 반환`() { + every { memberRepository.countByApproval(ApprovalStatus.PENDING) } returns 3L + every { memberRepository.countByApproval(ApprovalStatus.APPROVED) } returns 7L + every { memberRepository.countByApproval(ApprovalStatus.REJECTED) } returns 2L + + val member = createMember( + id = 1L, + isStaff = false, + approval = ApprovalStatus.PENDING + ) + + val profile = createProfile( + member = member, + name = "신청자", + part = Part.BACKEND, + school = "K대", + major = "컴공", + phone = "010-0000-1111", + profileImage = "PROFILE/1/a.png" + ) + + every { + memberProfileRepository.findByMemberApprovalIn( + listOf(ApprovalStatus.PENDING, ApprovalStatus.REJECTED), + any() + ) + } returns PageImpl(listOf(profile), PageRequest.of(0, 10), 1) + + every { + s3Service.getGetS3Url(1L, "PROFILE/1/a.png") + } returns GetS3UrlDto( + key = "PROFILE/1/a.png", + preSignedUrl = "https://profile-url", + originalName = "a.png" + ) + + val resp: MembersPagedResponse = + service.getApprovalRequestMembers(page = 0, size = 10) + + assertEquals(3L, resp.pendingCount) + assertEquals(7L, resp.approvedCount) + assertEquals(2L, resp.rejectedCount) + + assertEquals(1, resp.members.data.size) + val item = resp.members.data[0] + assertEquals(1L, item.memberId) + assertEquals("신청자", item.name) + assertEquals(ApprovalStatus.PENDING, item.approval) + assertEquals("https://profile-url", item.profileImageUrl) + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/member/service/MemberServiceTest.kt b/src/test/kotlin/onku/backend/member/service/MemberServiceTest.kt new file mode 100644 index 0000000..8e092ef --- /dev/null +++ b/src/test/kotlin/onku/backend/member/service/MemberServiceTest.kt @@ -0,0 +1,285 @@ +package onku.backend.member.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.dto.StaffUpdateRequest +import onku.backend.domain.member.dto.UpdateRoleRequest +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Role +import onku.backend.domain.member.enums.SocialType +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.member.service.MemberService +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.repository.findByIdOrNull +import java.util.* +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class MemberServiceTest { + + @MockK lateinit var memberRepository: MemberRepository + @MockK lateinit var memberProfileRepository: MemberProfileRepository + + lateinit var service: MemberService + + @BeforeEach + fun setUp() { + service = MemberService( + memberRepository = memberRepository, + memberProfileRepository = memberProfileRepository + ) + } + + private fun createMember( + id: Long = 1L, + email: String? = "test@aaa.com", + role: Role = Role.USER, + socialType: SocialType = SocialType.KAKAO, + socialId: String = "KAKAO", + hasInfo: Boolean = false, + approval: ApprovalStatus = ApprovalStatus.PENDING, + isStaff: Boolean = false + ): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + every { m.email } returns email + every { m.role } returns role + every { m.socialType } returns socialType + every { m.socialId } returns socialId + every { m.hasInfo } returns hasInfo + every { m.approval } returns approval + every { m.isStaff } returns isStaff + + // mutable property 는 set 도 열어줘야 할 때 있음 + every { m.role = any() } just Runs + every { m.isStaff = any() } just Runs + + return m + } + + // getByEmail + @Test + fun `getByEmail - 존재하는 이메일이면 Member 리턴`() { + val member = createMember(id = 1L, email = "user@test.com") + + every { memberRepository.findByEmail("user@test.com") } returns member + + val result = service.getByEmail("user@test.com") + + assertEquals(1L, result.id) + assertEquals("user@test.com", result.email) + } + + @Test + fun `getByEmail - 없으면 예외 발생`() { + every { memberRepository.findByEmail("no@test.com") } returns null + + val ex = assertThrows(CustomException::class.java) { + service.getByEmail("no@test.com") + } + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + } + + // upsertSocialMember + @Test + fun `upsertSocialMember - 기존 소셜 계정이 있으면 업데이트 후 그대로 리턴`() { + val existing = createMember( + id = 1L, + email = "old@test.com", + socialId = "KAKAO", + socialType = SocialType.KAKAO + ) + + every { memberRepository.findBySocialIdAndSocialType("KAKAO", SocialType.KAKAO) } returns existing + every { existing.updateEmail("new@test.com") } just Runs + + val result = service.upsertSocialMember( + email = "new@test.com", + socialId = "KAKAO", + type = SocialType.KAKAO + ) + + assertSame(existing, result) + verify(exactly = 0) { memberRepository.save(any()) } + verify(exactly = 1) { existing.updateEmail("new@test.com") } + } + + @Test + fun `upsertSocialMember - 기존 소셜 계정이 없으면 새로 생성`() { + every { memberRepository.findBySocialIdAndSocialType("KAKAO", SocialType.KAKAO) } returns null + every { memberRepository.findByEmail("user@test.com") } returns null + val slotMember = slot() + every { memberRepository.save(capture(slotMember)) } answers { slotMember.captured } + + val result = service.upsertSocialMember( + email = "user@test.com", + socialId = "KAKAO", + type = SocialType.KAKAO + ) + + // save 에 들어간 Member 검증 + assertEquals("user@test.com", slotMember.captured.email) + assertEquals(Role.USER, slotMember.captured.role) + assertEquals(SocialType.KAKAO, slotMember.captured.socialType) + assertEquals("KAKAO", slotMember.captured.socialId) + assertFalse(slotMember.captured.hasInfo) + assertEquals(ApprovalStatus.PENDING, slotMember.captured.approval) + + assertSame(slotMember.captured, result) + } + + // markOnboarded + @Test + fun `markOnboarded - 존재하지 않으면 예외`() { + val member = createMember(id = 1L) + + every { memberRepository.findById(1L) } returns Optional.empty() + + val ex = assertThrows(CustomException::class.java) { + service.markOnboarded(member) + } + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + } + + @Test + fun `markOnboarded - hasInfo false면 onboarded 호출하고 save`() { + val m = createMember( + id = 1L, + hasInfo = false + ) + + every { memberRepository.findById(1L) } returns Optional.of(m) + every { m.onboarded() } just Runs + every { memberRepository.save(m) } returns m + + service.markOnboarded(m) + + verify { m.onboarded() } + verify { memberRepository.save(m) } + } + + @Test + fun `markOnboarded - 이미 hasInfo true면 아무것도 안 함`() { + val m = createMember( + id = 1L, + hasInfo = true + ) + + every { memberRepository.findById(1L) } returns Optional.of(m) + + service.markOnboarded(m) + + // onboarded, save 호출 안 됨 + verify(exactly = 0) { m.onboarded() } + verify(exactly = 0) { memberRepository.save(any()) } + } + + // deleteMemberById + @Test + fun `deleteMemberById - 존재하지 않으면 예외`() { + every { memberRepository.existsById(1L) } returns false + + val ex = assertThrows(CustomException::class.java) { + service.deleteMemberById(1L) + } + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + } + + @Test + fun `deleteMemberById - 프로필 있으면 프로필 먼저 삭제 후 멤버 삭제`() { + every { memberRepository.existsById(1L) } returns true + every { memberProfileRepository.existsByMember_Id(1L) } returns true + every { memberProfileRepository.deleteByMemberId(1L) } returns 1 + every { memberRepository.deleteById(1L) } just Runs + + service.deleteMemberById(1L) + + verifyOrder { + memberProfileRepository.existsByMember_Id(1L) + memberProfileRepository.deleteByMemberId(1L) + memberRepository.deleteById(1L) + } + } + + @Test + fun `deleteMemberById - 프로필 없으면 멤버만 삭제`() { + every { memberRepository.existsById(1L) } returns true + every { memberProfileRepository.existsByMember_Id(1L) } returns false + every { memberRepository.deleteById(1L) } just Runs + + service.deleteMemberById(1L) + + verify(exactly = 0) { memberProfileRepository.deleteByMemberId(any()) } + verify(exactly = 1) { memberRepository.deleteById(1L) } + } + + // updateRole + @Test + fun `updateRole - role이 null이면 예외`() { + val req = UpdateRoleRequest(role = null) + + val ex = assertThrows(CustomException::class.java) { + service.updateRole(1L, req) + } + assertEquals(MemberErrorCode.INVALID_REQUEST, ex.errorCode) + } + + @Test + fun `updateRole - 정상적으로 역할 변경`() { + val member = createMember(id = 1L, role = Role.USER) + + every { memberRepository.findByIdOrNull(1L) } returns member + every { memberRepository.save(member) } returns member + + val req = UpdateRoleRequest(role = Role.MANAGEMENT) + + service.updateRole(1L, req) + + verify { member.role = Role.MANAGEMENT } + verify { memberRepository.save(member) } + } + + // updateStaffMembers + @Test + fun `updateStaffMembers - 기존 운영진과 비교해 추가와 삭제 목록을 계산한다`() { + val staff1 = createMember(id = 1L, isStaff = true, role = Role.STAFF) + val staff2 = createMember(id = 2L, isStaff = true, role = Role.STAFF) + + // 현재 운영진: 1, 2 + every { memberRepository.findByIsStaffTrue() } returns listOf(staff1, staff2) + + // 요청: 2, 3 이 운영진이 되어야 함 + val req = StaffUpdateRequest( + staffMemberIds = listOf(2L, 3L) + ) + + // 추가 대상으로 3L 멤버가 있다고 가정 + val newStaff = createMember(id = 3L, isStaff = false, role = Role.USER) + every { memberRepository.findByIdIn(setOf(3L)) } returns listOf(newStaff) + + val result = service.updateStaffMembers(req) + + // 추가된 운영진: 3, 제거된 운영진: 1 + assertEquals(listOf(3L), result.addedStaffs) + assertEquals(listOf(1L), result.removedStaffs) + + // 3은 isStaff true, role STAFF 로 바뀌어야 함 + verify { newStaff.isStaff = true } + verify { newStaff.role = Role.STAFF } + + // 1은 isStaff false, role USER 로 바뀌어야 함 + verify { staff1.isStaff = false } + verify { staff1.role = Role.USER } + + // 2는 그대로 staff 유지 (바꾸지 않아도 됨) + verify(exactly = 0) { staff2.isStaff = false } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/point/service/AdminPointCommandServiceTest.kt b/src/test/kotlin/onku/backend/point/service/AdminPointCommandServiceTest.kt new file mode 100644 index 0000000..ba8dd9e --- /dev/null +++ b/src/test/kotlin/onku/backend/point/service/AdminPointCommandServiceTest.kt @@ -0,0 +1,380 @@ +package onku.backend.point.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import onku.backend.domain.attendance.Attendance +import onku.backend.domain.attendance.AttendanceErrorCode +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.kupick.Kupick +import onku.backend.domain.kupick.KupickErrorCode +import onku.backend.domain.kupick.repository.KupickRepository +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.point.ManualPoint +import onku.backend.domain.point.dto.StudyPointsResult +import onku.backend.domain.point.repository.ManualPointRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.point.service.AdminPointCommandService +import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import java.time.Clock +import java.time.Instant +import java.time.YearMonth +import java.time.ZoneId +import java.util.* +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@ExtendWith(MockKExtension::class) +class AdminPointCommandServiceTest { + + @MockK lateinit var manualPointRecordRepository: ManualPointRepository + @MockK lateinit var memberRepository: MemberRepository + @MockK lateinit var kupickRepository: KupickRepository + @MockK lateinit var memberPointHistoryRepository: MemberPointHistoryRepository + @MockK lateinit var attendanceRepository: AttendanceRepository + @MockK lateinit var sessionRepository: SessionRepository + + lateinit var clock: Clock + lateinit var service: AdminPointCommandService + + @BeforeEach + fun setUp() { + clock = Clock.fixed( + Instant.parse("2025-01-01T01:00:00Z"), // Asia/Seoul → 10:00 + ZoneId.of("Asia/Seoul") + ) + + service = AdminPointCommandService( + manualPointRecordRepository = manualPointRecordRepository, + memberRepository = memberRepository, + kupickRepository = kupickRepository, + memberPointHistoryRepository = memberPointHistoryRepository, + attendanceRepository = attendanceRepository, + sessionRepository = sessionRepository, + clock = clock + ) + + every { memberPointHistoryRepository.save(any()) } answers { firstArg() } + } + + private fun createMember(id: Long = 1L): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + return m + } + + // updateStudyPoints + + @Test + fun `updateStudyPoints - 기존 레코드 있고 diff가 있으면 history 저장 + 값 갱신`() { + val member = createMember(1L) + val manual = ManualPoint( + member = member, + studyPoints = 10, + kupportersPoints = 0, + memo = null + ) + + every { manualPointRecordRepository.findByMemberId(1L) } returns manual + every { manualPointRecordRepository.save(any()) } answers { firstArg() } + + val result: StudyPointsResult = service.updateStudyPoints(1L, 30) + + assertEquals(1L, result.memberId) + assertEquals(30, result.studyPoints) + assertEquals(30, manual.studyPoints) + + // diff = 20 → history 1번 저장 + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + verify(exactly = 1) { manualPointRecordRepository.save(manual) } + } + + @Test + fun `updateStudyPoints - diff가 0이면 history는 저장하지 않는다`() { + val member = createMember(1L) + val manual = ManualPoint( + member = member, + studyPoints = 20, + kupportersPoints = 0, + memo = null + ) + + every { manualPointRecordRepository.findByMemberId(1L) } returns manual + every { manualPointRecordRepository.save(any()) } answers { firstArg() } + + val result = service.updateStudyPoints(1L, 20) + + assertEquals(20, result.studyPoints) + assertEquals(20, manual.studyPoints) + + // history 저장 X + verify(exactly = 0) { memberPointHistoryRepository.save(any()) } + verify(exactly = 1) { manualPointRecordRepository.save(manual) } + } + + @Test + fun `updateStudyPoints - 기존 레코드 없으면 newManualRecord 생성해서 사용`() { + val member = createMember(1L) + + every { manualPointRecordRepository.findByMemberId(1L) } returns null + every { memberRepository.getReferenceById(1L) } returns member + every { manualPointRecordRepository.save(any()) } answers { firstArg() } + + val result = service.updateStudyPoints(1L, 15) + + assertEquals(1L, result.memberId) + assertEquals(15, result.studyPoints) + + // getReferenceById 통해 ManualPoint 생성 + verify { memberRepository.getReferenceById(1L) } + verify { manualPointRecordRepository.save(any()) } + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } // diff = 15 + } + + // updateKupportersPoints & updateMemo + + @Test + fun `updateKupportersPoints - kupporters 포인트 변경 시 history와 레코드 갱신`() { + val member = createMember(1L) + val manual = ManualPoint( + member = member, + studyPoints = 0, + kupportersPoints = 5, + memo = null + ) + + every { manualPointRecordRepository.findByMemberId(1L) } returns manual + every { manualPointRecordRepository.save(any()) } answers { firstArg() } + + val result = service.updateKupportersPoints(1L, 8) + + assertEquals(1L, result.memberId) + assertEquals(8, result.kupportersPoints) + assertEquals(8, manual.kupportersPoints) + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + } + + @Test + fun `updateMemo - 메모만 변경하고 history는 기록하지 않는다`() { + val member = createMember(1L) + val manual = ManualPoint( + member = member, + studyPoints = 0, + kupportersPoints = 0, + memo = "old" + ) + + every { manualPointRecordRepository.findByMemberId(1L) } returns manual + every { manualPointRecordRepository.save(any()) } answers { firstArg() } + + val result = service.updateMemo(1L, "new memo") + + assertEquals(1L, result.memberId) + assertEquals("new memo", result.memo) + assertEquals("new memo", manual.memo) + verify(exactly = 0) { memberPointHistoryRepository.save(any()) } + } + + // updateIsTf + + @Test + fun `updateIsTf - false에서 true로 토글되면 TF 포인트 추가되고 true 반환`() { + val member = createMember(1L) + every { member.isTf } returns false + every { member.isTf = any() } just Runs + every { memberRepository.findById(1L) } returns Optional.of(member) + + val result = service.updateIsTf(1L) + + assertTrue(result) + verify { member.isTf = true } + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + } + + @Test + fun `updateIsTf - 회원 없으면 예외`() { + every { memberRepository.findById(1L) } returns Optional.empty() + + val ex = assertFailsWith { + service.updateIsTf(1L) + } + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + } + + // updateIsStaff + @Test + fun `updateIsStaff - false에서 true로 토글되면 STAFF 포인트 추가`() { + val member = createMember(1L) + every { member.isStaff } returns false + every { member.isStaff = any() } just Runs + every { memberRepository.findById(1L) } returns Optional.of(member) + + val result = service.updateIsStaff(1L) + + assertTrue(result) + verify { member.isStaff = true } + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + } + + // updateKupickApproval + + @Test + fun `updateKupickApproval - 기존 Kupick이 있으면 approval 토글 및 history 기록`() { + val member = createMember(1L) + val targetYm = YearMonth.of(2025, 1) + + val kupick = mockk(relaxed = true) + every { kupick.id } returns 10L + every { kupick.approval } returns false + every { kupick.updateApproval(any()) } just Runs + + every { memberRepository.findById(1L) } returns Optional.of(member) + every { + kupickRepository.findThisMonthByMember(member, any(), any()) + } returns kupick + every { kupickRepository.save(kupick) } returns kupick + + val result = service.updateKupickApproval(1L, targetYm) + + assertEquals(1L, result.memberId) + assertEquals(10L, result.kupickId) + assertTrue(result.isKupick) + + verify { kupick.updateApproval(true) } + verify { memberPointHistoryRepository.save(any()) } + verify { kupickRepository.save(kupick) } + } + + @Test + fun `updateKupickApproval - 회원 없으면 예외`() { + every { memberRepository.findById(1L) } returns Optional.empty() + + val ex = assertFailsWith { + service.updateKupickApproval(1L, YearMonth.of(2025, 1)) + } + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + } + + @Test + fun `updateKupickApproval - save 후 id null이면 예외`() { + val member = createMember(1L) + val kupick = mockk(relaxed = true) + every { kupick.id } returns null + + every { memberRepository.findById(1L) } returns Optional.of(member) + every { kupickRepository.findThisMonthByMember(member, any(), any()) } returns kupick + every { kupickRepository.save(kupick) } returns kupick + + val ex = assertFailsWith { + service.updateKupickApproval(1L, YearMonth.of(2025, 1)) + } + assertEquals(KupickErrorCode.KUPICK_SAVE_FAILED, ex.errorCode) + } + + // updateAttendanceAndHistory + + @Test + fun `updateAttendanceAndHistory - attendance가 없으면 예외`() { + every { attendanceRepository.findById(1L) } returns Optional.empty() + + val ex = assertFailsWith { + service.updateAttendanceAndHistory( + attendanceId = 1L, + memberId = 1L, + newStatus = AttendancePointType.LATE + ) + } + assertEquals(AttendanceErrorCode.ATTENDANCE_NOT_FOUND, ex.errorCode) + } + + @Test + fun `updateAttendanceAndHistory - attendance의 memberId가 다르면 예외`() { + val attendance = mockk(relaxed = true) + every { attendance.memberId } returns 2L + every { attendanceRepository.findById(1L) } returns Optional.of(attendance) + + val ex = assertFailsWith { + service.updateAttendanceAndHistory( + attendanceId = 1L, + memberId = 1L, + newStatus = AttendancePointType.LATE + ) + } + assertEquals(AttendanceErrorCode.INVALID_MEMBER_FOR_ATTENDANCE, ex.errorCode) + } + + @Test + fun `updateAttendanceAndHistory - 상태가 같으면 저장 없이 diff 0으로 반환`() { + val attendance = mockk(relaxed = true) + every { attendance.memberId } returns 1L + every { attendance.sessionId } returns 10L + every { attendance.status } returns AttendancePointType.PRESENT + + val session = mockk(relaxed = true) + every { session.week } returns 3L + + every { attendanceRepository.findById(1L) } returns Optional.of(attendance) + every { sessionRepository.findById(10L) } returns Optional.of(session) + + val result = service.updateAttendanceAndHistory( + attendanceId = 1L, + memberId = 1L, + newStatus = AttendancePointType.PRESENT + ) + + assertEquals(0, result.diff) + assertEquals(AttendancePointType.PRESENT, result.oldStatus) + assertEquals(AttendancePointType.PRESENT, result.newStatus) + assertEquals(3L, result.week) + + verify(exactly = 0) { attendanceRepository.save(any()) } + verify(exactly = 0) { memberPointHistoryRepository.save(any()) } + } + + @Test + fun `updateAttendanceAndHistory - 상태가 변경되면 attendance와 history 저장`() { + val attendance = mockk(relaxed = true) + every { attendance.memberId } returns 1L + every { attendance.sessionId } returns 10L + every { attendance.status } returns AttendancePointType.ABSENT + every { attendance.status = any() } just Runs + every { attendanceRepository.save(any()) } answers { firstArg() } + + val session = mockk(relaxed = true) + every { session.week } returns 2L + + val member = createMember(1L) + + every { attendanceRepository.findById(1L) } returns Optional.of(attendance) + every { sessionRepository.findById(10L) } returns Optional.of(session) + every { memberRepository.findById(1L) } returns Optional.of(member) + + val result = service.updateAttendanceAndHistory( + attendanceId = 1L, + memberId = 1L, + newStatus = AttendancePointType.PRESENT + ) + + // PRESENT.points - ABSENT.points 의 diff + val expectedDiff = AttendancePointType.PRESENT.points - AttendancePointType.ABSENT.points + + assertEquals(AttendancePointType.ABSENT, result.oldStatus) + assertEquals(AttendancePointType.PRESENT, result.newStatus) + assertEquals(expectedDiff, result.diff) + assertEquals(2L, result.week) + + verify { attendance.status = AttendancePointType.PRESENT } + verify(exactly = 1) { attendanceRepository.save(attendance) } + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt b/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt new file mode 100644 index 0000000..e196fcd --- /dev/null +++ b/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt @@ -0,0 +1,329 @@ +package onku.backend.point.service + +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import onku.backend.domain.attendance.Attendance +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.kupick.repository.KupickRepository +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.enums.Part +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.point.ManualPoint +import onku.backend.domain.point.dto.AdminPointOverviewDto +import onku.backend.domain.point.dto.MemberMonthlyAttendanceDto +import onku.backend.domain.point.repository.ManualPointRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.point.service.AdminPointService +import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.page.PageResponse +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import java.time.* +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@ExtendWith(MockKExtension::class) +class AdminPointServiceTest { + + @MockK lateinit var memberProfileRepository: MemberProfileRepository + @MockK lateinit var kupickRepository: KupickRepository + @MockK lateinit var manualPointRecordRepository: ManualPointRepository + @MockK lateinit var sessionRepository: SessionRepository + @MockK lateinit var memberPointHistoryRepository: MemberPointHistoryRepository + @MockK lateinit var attendanceRepository: AttendanceRepository + + lateinit var clock: Clock + lateinit var service: AdminPointService + + @BeforeEach + fun setUp() { + clock = Clock.fixed( + Instant.parse("2025-01-01T01:00:00Z"), // Asia/Seoul → 10:00 + ZoneId.of("Asia/Seoul") + ) + + service = AdminPointService( + memberProfileRepository = memberProfileRepository, + kupickRepository = kupickRepository, + manualPointRecordRepository = manualPointRecordRepository, + sessionRepository = sessionRepository, + memberPointHistoryRepository = memberPointHistoryRepository, + attendanceRepository = attendanceRepository, + clock = clock + ) + } + + private fun createMember( + id: Long = 1L, + isTf: Boolean = false, + isStaff: Boolean = false + ): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + every { m.isTf } returns isTf + every { m.isStaff } returns isStaff + return m + } + + private fun createProfile( + memberId: Long, + member: Member, + name: String = "홍길동", + part: Part = Part.BACKEND, + phone: String? = "010-0000-0000", + school: String? = "K대학교", + major: String? = "컴퓨터공학" + ): MemberProfile { + val p = mockk(relaxed = true) + every { p.memberId } returns memberId + every { p.member } returns member + every { p.name } returns name + every { p.part } returns part + every { p.phoneNumber } returns phone + every { p.school } returns school + every { p.major } returns major + return p + } + + // getAdminOverview + + @Test + fun `getAdminOverview - 한 명에 대해 월별 출석, 큐픽 참여, 수동 포인트를 잘 매핑한다`() { + val year = 2025 + val page = 0 + val size = 10 + val pageable = PageRequest.of(page, size) + + val member = createMember(id = 1L, isTf = true, isStaff = false) + val profile = createProfile(memberId = 1L, member = member) + + every { + memberProfileRepository.findAllByOrderByPartAscNameAsc(any()) + } returns PageImpl(listOf(profile), pageable, 1) + + // 1) 출석 포인트 합산 결과 (8~12월 중 9월만 30점 있다고 가정) + every { + memberPointHistoryRepository.sumAttendanceByMemberAndMonth( + memberIds = listOf(1L), + category = any(), + start = any(), + end = any() + ) + } returns listOf( + mockk { + every { getMemberId() } returns 1L + every { getMonth() } returns 9 + every { getPoints() } returns 30L + } + ) + + // 2) 큐픽 참여 여부 (8~12 중 10월에만 참여) + every { + kupickRepository.findMemberMonthParticipation( + listOf(1L), + any(), + any() + ) + } returns listOf( + arrayOf(1L, 10) // memberId, month + ) + + // 3) 수동 포인트 (study = 5, kuporters = 7) + val manual = mockk() + + every { manual.memberId } returns 1L + every { manual.studyPoints } returns 5 + every { manual.kupportersPoints } returns 7 + every { manual.memo } returns "열심히 함" + every { manual.member } returns member // isTf, isStaff 볼 수도 있으니 안전하게 + + every { + manualPointRecordRepository.findByMemberIdIn(listOf(1L)) + } returns listOf(manual) + + val response: PageResponse = + service.getAdminOverview(year = year, page = page, size = size) + + assertEquals(1, response.data.size) + + val dto = response.data[0] + assertEquals(1L, dto.memberId) + assertEquals("홍길동", dto.name) + assertEquals(Part.BACKEND, dto.part) + assertEquals("010-0000-0000", dto.phoneNumber) + assertEquals("K대학교", dto.school) + assertEquals("컴퓨터공학", dto.major) + assertTrue(dto.isTf) + assertFalse(dto.isStaff) + + // 8~12월 키가 모두 있어야 함 + assertEquals(setOf(8, 9, 10, 11, 12), dto.attendanceMonthlyTotals.keys) + // 9월만 30점 + assertEquals(30, dto.attendanceMonthlyTotals[9]) + assertEquals(0, dto.attendanceMonthlyTotals[8]) + assertEquals(0, dto.attendanceMonthlyTotals[10]) + + // 큐픽 참여: 10월만 true + assertEquals(setOf(8, 9, 10, 11, 12), dto.kupickParticipation.keys) + assertEquals(true, dto.kupickParticipation[10]) + assertEquals(false, dto.kupickParticipation[8]) + + // 수동 포인트 + assertEquals(5, dto.studyPoints) + assertEquals(7, dto.kuportersPoints) + assertEquals("열심히 함", dto.memo) + } + + @Test + fun `getAdminOverview - 멤버가 아예 없으면 빈 페이지 반환`() { + val pageable = PageRequest.of(0, 10) + every { + memberProfileRepository.findAllByOrderByPartAscNameAsc(any()) + } returns PageImpl(emptyList(), pageable, 0) + + val response = service.getAdminOverview( + year = 2025, + page = 0, + size = 10 + ) + + assertTrue(response.data.isEmpty()) + } + + // getMonthlyPaged + + @Test + fun `getMonthlyPaged - 해당 월 세션이 하나도 없으면 sessionDates와 members 비어있다`() { + every { + sessionRepository.findByStartDateBetween(any(), any()) + } returns emptyList() + + val result = service.getMonthlyPaged( + year = 2025, + month = 1, + page = 0, + size = 10 + ) + + assertEquals(2025, result.year) + assertEquals(1, result.month) + assertTrue(result.sessionDates.isEmpty()) + assertTrue(result.members.data.isEmpty()) + } + + @Test + fun `getMonthlyPaged - 세션 2개, 출석 1개만 있을 때 나머지 날짜는 status null로 채운다`() { + val year = 2025 + val month = 1 + + // 세션 2개: 1월 5일, 1월 20일 + val session1 = mockk(relaxed = true) + every { session1.id } returns 10L + every { session1.startDate } returns LocalDate.of(year, month, 5) + + val session2 = mockk(relaxed = true) + every { session2.id } returns 11L + every { session2.startDate } returns LocalDate.of(year, month, 20) + + every { + sessionRepository.findByStartDateBetween(any(), any()) + } returns listOf(session1, session2) + + // 페이지 멤버 1명 + val pageable = PageRequest.of(0, 10) + val member = createMember(1L) + val profile = createProfile(memberId = 1L, member = member, name = "테스터") + + every { + memberProfileRepository.findAllByOrderByPartAscNameAsc(pageable) + } returns PageImpl(listOf(profile), pageable, 1) + + // 출석: 이 멤버는 1월 5일(세션1)에만 PRESENT + val attendance = mockk(relaxed = true) + every { attendance.id } returns 100L + every { attendance.memberId } returns 1L + every { attendance.sessionId } returns 10L + every { attendance.status } returns AttendancePointType.PRESENT + every { attendance.attendanceTime } returns LocalDateTime.of(year, month, 5, 10, 0) + + every { + attendanceRepository.findByMemberIdInAndAttendanceTimeBetween( + listOf(1L), + any(), + any() + ) + } returns listOf(attendance) + + val result = service.getMonthlyPaged( + year = year, + month = month, + page = 0, + size = 10 + ) + + assertEquals(year, result.year) + assertEquals(month, result.month) + // sessionDates = [5, 20] + assertEquals(listOf(5, 20), result.sessionDates) + + // 멤버는 한 명 + assertEquals(1, result.members.data.size) + val memberDto: MemberMonthlyAttendanceDto = result.members.data[0] + assertEquals(1L, memberDto.memberId) + assertEquals("테스터", memberDto.name) + + // records 는 날짜 2개에 대해 존재해야 한다 + assertEquals(2, memberDto.records.size) + val rec1 = memberDto.records[0] + val rec2 = memberDto.records[1] + + assertEquals(LocalDate.of(year, month, 5), rec1.date) + assertEquals(AttendancePointType.PRESENT, rec1.status) + assertEquals(AttendancePointType.PRESENT.points, rec1.point) + + assertEquals(LocalDate.of(year, month, 20), rec2.date) + assertNull(rec2.status) + assertNull(rec2.point) + } + + @Test + fun `getMonthlyPaged - 페이지에 멤버가 없으면 예외 발생`() { + val year = 2025 + val month = 1 + + // 세션은 있는데 + val session = mockk(relaxed = true) + every { session.id } returns 10L + every { session.startDate } returns LocalDate.of(year, month, 5) + every { + sessionRepository.findByStartDateBetween(any(), any()) + } returns listOf(session) + + // member 페이지는 비어있음 + val pageable = PageRequest.of(0, 10) + every { + memberProfileRepository.findAllByOrderByPartAscNameAsc(pageable) + } returns PageImpl(emptyList(), pageable, 0) + + val ex = assertFailsWith { + service.getMonthlyPaged( + year = year, + month = month, + page = 0, + size = 10 + ) + } + + assertEquals(MemberErrorCode.PAGE_MEMBERS_NOT_FOUND, ex.errorCode) + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/point/service/MemberPointHistoryServiceTest.kt b/src/test/kotlin/onku/backend/point/service/MemberPointHistoryServiceTest.kt new file mode 100644 index 0000000..66ac8ca --- /dev/null +++ b/src/test/kotlin/onku/backend/point/service/MemberPointHistoryServiceTest.kt @@ -0,0 +1,260 @@ +package onku.backend.point.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import onku.backend.domain.absence.AbsenceReport +import onku.backend.domain.absence.enums.AbsenceApprovedType +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.attendance.util.AbsenceReportToAttendancePointMapper +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.point.MemberPointHistory +import onku.backend.domain.point.converter.MemberPointConverter +import onku.backend.domain.point.dto.MemberPointHistoryResponse +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.point.service.MemberPointHistoryService +import onku.backend.domain.session.Session +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@ExtendWith(MockKExtension::class) +class MemberPointHistoryServiceTest { + + @MockK lateinit var recordRepository: MemberPointHistoryRepository + @MockK lateinit var memberProfileRepository: MemberProfileRepository + + lateinit var service: MemberPointHistoryService + + @BeforeEach + fun setUp() { + service = MemberPointHistoryService( + recordRepository = recordRepository, + memberProfileRepository = memberProfileRepository + ) + } + + private fun createMember(id: Long = 1L): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + return m + } + + private fun createProfile(member: Member, name: String? = "홍길동"): MemberProfile { + val p = mockk(relaxed = true) + every { p.memberId } returns member.id + every { p.member } returns member + every { p.name } returns name + return p + } + + // getHistory + + @Test + fun `getHistory - 누적 포인트와 기록 페이지를 잘 매핑한다`() { + val member = createMember(1L) + val pageIndex = 0 + val size = 10 + val pageable = PageRequest.of(pageIndex, size) + + val profile = createProfile(member, "김테스터") + every { memberProfileRepository.findById(1L) } returns java.util.Optional.of(profile) + + val sums = mockk { + every { getPlusPoints() } returns 100L + every { getMinusPoints() } returns 20L + every { getTotalPoints() } returns 80L + } + every { recordRepository.sumPointsForMember(member) } returns sums + + // 히스토리 엔티티 + 페이지 + val history1 = mockk(relaxed = true) + val history2 = mockk(relaxed = true) + + every { + recordRepository.findByMemberOrderByOccurredAtDesc(member, pageable) + } returns PageImpl(listOf(history1, history2), pageable, 2) + + // Converter mock + mockkObject(MemberPointConverter) + val dto1 = mockk() + val dto2 = mockk() + + every { MemberPointConverter.toResponse(history1) } returns dto1 + every { MemberPointConverter.toResponse(history2) } returns dto2 + + // when + val response: MemberPointHistoryResponse = + service.getHistory(member, pageIndex, size) + + // then + assertEquals(1L, response.memberId) + assertEquals("김테스터", response.name) + assertEquals(100, response.plusPoints) + assertEquals(20, response.minusPoints) + assertEquals(80, response.totalPoints) + + assertEquals(2, response.records.size) + assertSame(dto1, response.records[0]) + assertSame(dto2, response.records[1]) + + assertEquals(1, response.totalPages) // totalElements=2, size=10 → totalPages=1 + assertTrue(response.isLastPage) + + verify { memberProfileRepository.findById(1L) } + verify { recordRepository.sumPointsForMember(member) } + verify { recordRepository.findByMemberOrderByOccurredAtDesc(member, pageable) } + } + + @Test + fun `getHistory - 프로필이 없으면 MEMBER_NOT_FOUND 예외`() { + val member = createMember(1L) + every { memberProfileRepository.findById(1L) } returns java.util.Optional.empty() + + val ex = assertFailsWith { + service.getHistory(member, safePage = 0, size = 10) + } + + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + verify { memberProfileRepository.findById(1L) } + verify(exactly = 0) { recordRepository.sumPointsForMember(any()) } + } + + // upsertPointFromAbsenceReport + + @Test + fun `upsertPointFromAbsenceReport - 기존 기록 없으면 새로 생성해서 저장`() { + val member = createMember(1L) + val session = mockk(relaxed = true) + every { session.week } returns 3L + + val absence = mockk(relaxed = true) + every { absence.member } returns member + every { absence.session } returns session + every { absence.approval } returns AbsenceReportApproval.APPROVED + every { absence.approvedType } returns AbsenceApprovedType.EXCUSED + + every { + recordRepository.findByWeekAndMember(3L, member) + } returns null + + // mapper + factory mock + mockkObject(AbsenceReportToAttendancePointMapper) + every { + AbsenceReportToAttendancePointMapper.map( + AbsenceReportApproval.APPROVED, + AbsenceApprovedType.EXCUSED + ) + } returns AttendancePointType.EXCUSED + + mockkObject(MemberPointHistory) + val newHistory = mockk(relaxed = true) + + every { + MemberPointHistory.ofAttendance( + member = member, + status = AttendancePointType.EXCUSED, + occurredAt = any(), + week = 3L + ) + } returns newHistory + + every { recordRepository.save(newHistory) } returns newHistory + + // when + service.upsertPointFromAbsenceReport(absence) + + // then + verify { + recordRepository.findByWeekAndMember(3L, member) + } + verify { + AbsenceReportToAttendancePointMapper.map( + AbsenceReportApproval.APPROVED, + AbsenceApprovedType.EXCUSED + ) + } + verify { + MemberPointHistory.ofAttendance( + member = member, + status = AttendancePointType.EXCUSED, + occurredAt = any(), + week = 3L + ) + } + verify { recordRepository.save(newHistory) } + } + + @Test + fun `upsertPointFromAbsenceReport - 기존 기록 있으면 updateAttendancePointType 호출 후 save`() { + val member = createMember(1L) + val session = mockk(relaxed = true) + every { session.week } returns 4L + + val absence = mockk(relaxed = true) + every { absence.member } returns member + every { absence.session } returns session + every { absence.approval } returns AbsenceReportApproval.APPROVED + every { absence.approvedType } returns AbsenceApprovedType.ABSENT + + val existingHistory = mockk(relaxed = true) + + every { + recordRepository.findByWeekAndMember(4L, member) + } returns existingHistory + + mockkObject(AbsenceReportToAttendancePointMapper) + every { + AbsenceReportToAttendancePointMapper.map( + AbsenceReportApproval.APPROVED, + AbsenceApprovedType.ABSENT + ) + } returns AttendancePointType.ABSENT + + every { + existingHistory.updateAttendancePointType( + status = AttendancePointType.ABSENT, + occurredAt = any() + ) + } just Runs + + every { recordRepository.save(existingHistory) } returns existingHistory + + // when + service.upsertPointFromAbsenceReport(absence) + + // then + verify { + recordRepository.findByWeekAndMember(4L, member) + } + verify { + AbsenceReportToAttendancePointMapper.map( + AbsenceReportApproval.APPROVED, + AbsenceApprovedType.ABSENT + ) + } + verify { + existingHistory.updateAttendancePointType( + status = AttendancePointType.ABSENT, + occurredAt = any() + ) + } + verify { recordRepository.save(existingHistory) } + + // 새로 만드는 ofAttendance는 호출되면 안 됨 + mockkObject(MemberPointHistory) + verify(exactly = 0) { + MemberPointHistory.ofAttendance(any(), any(), any(), any()) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/session/service/SessionDetailServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionDetailServiceTest.kt new file mode 100644 index 0000000..1078746 --- /dev/null +++ b/src/test/kotlin/onku/backend/session/service/SessionDetailServiceTest.kt @@ -0,0 +1,212 @@ +package onku.backend.session.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import onku.backend.domain.attendance.finalize.FinalizeEvent +import onku.backend.domain.session.Session +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.dto.request.UpsertSessionDetailRequest +import onku.backend.domain.session.repository.SessionDetailRepository +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.service.SessionDetailService +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.context.ApplicationEventPublisher +import java.time.LocalTime +import java.util.* +import kotlin.test.Test +@ExtendWith(MockKExtension::class) +class SessionDetailServiceTest { + @MockK lateinit var sessionDetailRepository: SessionDetailRepository + @MockK lateinit var sessionRepository: SessionRepository + @MockK(relaxUnitFun = true) lateinit var applicationEventPublisher: ApplicationEventPublisher + + lateinit var service: SessionDetailService + + @BeforeEach + fun setUp() { + service = SessionDetailService( + sessionDetailRepository = sessionDetailRepository, + sessionRepository = sessionRepository, + applicationEventPublisher = applicationEventPublisher + ) + clearMocks(sessionDetailRepository, sessionRepository, applicationEventPublisher) + + } + + private fun createSession(id: Long = 10L): Session { + val s = mockk(relaxed = true) + every { s.id } returns id + every { s.sessionDetail = any() } just Runs + return s + } + + // sessionDetailId 존재 → 기존 상세 수정 + @Test + fun `upsertSessionDetail - 기존 상세가 있으면 수정한다`() { + val session = createSession(id = 100L) + + val existingDetail = SessionDetail( + id = 1L, + place = "기존 장소", + startTime = LocalTime.of(9, 0), + endTime = LocalTime.of(11, 0), + content = "기존 내용" + ) + + val request = UpsertSessionDetailRequest( + sessionId = 10L, + sessionDetailId = 1L, + place = "새 장소", + startTime = LocalTime.of(10, 0), + endTime = LocalTime.of(12, 0), + content = "새 내용" + ) + + every { sessionDetailRepository.findById(1L) } returns Optional.of(existingDetail) + every { sessionRepository.save(session) } returns session + // Unit 메서드라서 just Runs (혹은 @MockK(relaxUnitFun = true)면 없어도 됨) + every { applicationEventPublisher.publishEvent(any()) } just Runs + + // when + val returnedId = service.upsertSessionDetail(session, request) + + // then - detail 변경 확인 + assertEquals(1L, returnedId) + assertEquals("새 장소", existingDetail.place) + assertEquals(LocalTime.of(10, 0), existingDetail.startTime) + assertEquals(LocalTime.of(12, 0), existingDetail.endTime) + assertEquals("새 내용", existingDetail.content) + + // 세션에 detail 연결 + 세션 저장 + verify { session.sessionDetail = existingDetail } + verify { sessionRepository.save(session) } + + // FinalizeEvent 발행됐는지만 확인 (runAt까지는 굳이 안 봐도 됨) + val eventSlot = slot() + verify { + applicationEventPublisher.publishEvent(capture(eventSlot)) + } + assertEquals(100L, eventSlot.captured.sessionId) // 세션 ID만 검증 + // runAt은 null 아니면 됐다 정도만 보고 싶으면: + // assertNotNull(eventSlot.captured.runAt) + } + + // sessionDetailId 없음 → 새 상세 생성 + @Test + fun `upsertSessionDetail - sessionDetailId가 없으면 새로 생성한다`() { + val session = createSession(id = 200L) + + val request = UpsertSessionDetailRequest( + sessionId = 10L, + sessionDetailId = null, + place = "강의실 101", + startTime = LocalTime.of(13, 0), + endTime = LocalTime.of(15, 0), + content = "세션 내용" + ) + + val savedDetail = SessionDetail( + id = 10L, + place = request.place, + startTime = request.startTime, + endTime = request.endTime, + content = request.content + ) + + every { sessionDetailRepository.save(any()) } returns savedDetail + every { sessionRepository.save(session) } returns session + every { applicationEventPublisher.publishEvent(any()) } just Runs + + // when + val returnedId = service.upsertSessionDetail(session, request) + + // then + assertEquals(10L, returnedId) + + // 새 detail이 기대한 값으로 save 되었는지 검증 + verify { + sessionDetailRepository.save( + match { + it.place == "강의실 101" && + it.startTime == LocalTime.of(13, 0) && + it.endTime == LocalTime.of(15, 0) && + it.content == "세션 내용" + } + ) + } + + // 세션에 detail 연결 + 세션 저장 + verify { session.sessionDetail = savedDetail } + verify { sessionRepository.save(session) } + + // FinalizeEvent 발행 확인 + val eventSlot = slot() + verify { applicationEventPublisher.publishEvent(capture(eventSlot)) } + + assertEquals(200L, eventSlot.captured.sessionId) + assertNotNull(eventSlot.captured.runAt) + } + + // sessionDetailId 있는데 DB에 없음 → 예외 + @Test + fun `upsertSessionDetail - 기존 상세 없으면 예외`() { + val session = createSession(id = 300L) + + val request = UpsertSessionDetailRequest( + sessionId = 10L, + sessionDetailId = 999L, + place = "어딘가", + startTime = LocalTime.of(10, 0), + endTime = LocalTime.of(11, 0), + content = "내용" + ) + + every { sessionDetailRepository.findById(999L) } returns Optional.empty() + + val ex = assertThrows { + service.upsertSessionDetail(session, request) + } + + assertEquals(SessionErrorCode.SESSION_DETAIL_NOT_FOUND, ex.errorCode) + verify(exactly = 0) { sessionRepository.save(any()) } + verify(exactly = 0) { applicationEventPublisher.publishEvent(any()) } + } + + // getById - 정상 + @Test + fun `getById - 정상 조회`() { + val detail = SessionDetail( + id = 5L, + place = "강의실", + startTime = LocalTime.of(9, 0), + endTime = LocalTime.of(11, 0), + content = "내용" + ) + + every { sessionDetailRepository.findById(5L) } returns Optional.of(detail) + + val found = service.getById(5L) + + assertEquals(5L, found.id) + assertEquals("강의실", found.place) + } + + // getById - 못 찾으면 예외 + @Test + fun `getById - 상세 없으면 예외`() { + every { sessionDetailRepository.findById(100L) } returns Optional.empty() + + val ex = assertThrows { + service.getById(100L) + } + + assertEquals(SessionErrorCode.SESSION_DETAIL_NOT_FOUND, ex.errorCode) + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/session/service/SessionImageServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionImageServiceTest.kt new file mode 100644 index 0000000..fd74f08 --- /dev/null +++ b/src/test/kotlin/onku/backend/session/service/SessionImageServiceTest.kt @@ -0,0 +1,142 @@ +package onku.backend.session.service + +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.SessionImage +import onku.backend.domain.session.repository.SessionImageRepository +import onku.backend.domain.session.service.SessionImageService +import onku.backend.global.exception.CustomException +import onku.backend.global.s3.dto.GetS3UrlDto +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import java.util.* +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class SessionImageServiceTest { + + @MockK(relaxUnitFun = true) + lateinit var sessionImageRepository: SessionImageRepository + + lateinit var sessionImageService: SessionImageService + + @BeforeEach + fun setUp() { + sessionImageService = SessionImageService(sessionImageRepository) + } + + private fun createSessionDetail(id: Long = 1L): SessionDetail { + // 진짜 엔티티를 써도 되고 mockk로 써도 됨. 여기선 간단히 mock 사용. + val detail = mockk(relaxed = true) + every { detail.id } returns id + return detail + } + + // uploadImages + @Test + fun `uploadImages - SessionDetail과 url로 SessionImage를 생성하고 저장한다`() { + val detail = createSessionDetail(1L) + + val preSignedImages = listOf( + GetS3UrlDto(key = "SESSION/1/A/img1.png", preSignedUrl = "https://...1", originalName = "img1.png"), + GetS3UrlDto(key = "SESSION/1/A/img2.png", preSignedUrl = "https://...2", originalName = "img2.png"), + ) + + // saveAll이 들어온 그대로 돌려주도록 설정 (엔티티 equals 안 맞아도 문제 없게) + every { sessionImageRepository.saveAll(any>()) } answers { + firstArg>().toList() + } + + // when + val result: List = sessionImageService.uploadImages(detail, preSignedImages) + + // then: repo.saveAll이 SessionImage 리스트로 호출됐는지 + verify (exactly = 1) { + sessionImageRepository.saveAll( + match> { images -> + images.count() == 2 && + images.all { it.sessionDetail == detail } && + images.map { it.url }.toSet() == setOf( + "SESSION/1/A/img1.png", + "SESSION/1/A/img2.png" + ) + } + ) + } + + assertEquals(2, result.size) + assertEquals("SESSION/1/A/img1.png", result[0].url) + assertEquals("SESSION/1/A/img2.png", result[1].url) + assertEquals(detail, result[0].sessionDetail) + } + + // deleteImage + @Test + fun `deleteImage - id 기반으로 삭제 요청을 보낸다`() { + val imageId = 10L + + // when + sessionImageService.deleteImage(imageId) + + // then + verify(exactly = 1) { sessionImageRepository.deleteById(imageId) } + } + + // getById + @Test + fun `getById - 이미지가 존재하면 반환한다`() { + val imageId = 5L + val detail = createSessionDetail(1L) + val image = SessionImage( + id = imageId, + sessionDetail = detail, + url = "SESSION/1/A/img.png" + ) + + every { sessionImageRepository.findById(imageId) } returns Optional.of(image) + + val result = sessionImageService.getById(imageId) + + assertSame(image, result) + } + + @Test + fun `getById - 이미지가 없으면 예외를 던진다`() { + val imageId = 999L + every { sessionImageRepository.findById(imageId) } returns Optional.empty() + + val ex = assertThrows { + sessionImageService.getById(imageId) + } + + assertEquals(SessionErrorCode.SESSION_IMAGE_NOT_FOUND, ex.errorCode) + } + + // findAllBySessionDetailId + @Test + fun `findAllBySessionDetailId - detailId로 모든 이미지를 조회한다`() { + val detailId = 3L + val detail = createSessionDetail(detailId) + + val images = listOf( + SessionImage(id = 1L, sessionDetail = detail, url = "SESSION/3/A/img1.png"), + SessionImage(id = 2L, sessionDetail = detail, url = "SESSION/3/A/img2.png"), + ) + + every { sessionImageRepository.findAllBySessionDetailId(detailId) } returns images + + val result = sessionImageService.findAllBySessionDetailId(detailId) + + assertEquals(2, result.size) + assertEquals(images, result) + verify(exactly = 1) { sessionImageRepository.findAllBySessionDetailId(detailId) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/session/service/SessionNoticeServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionNoticeServiceTest.kt new file mode 100644 index 0000000..6f794fa --- /dev/null +++ b/src/test/kotlin/onku/backend/session/service/SessionNoticeServiceTest.kt @@ -0,0 +1,120 @@ +package onku.backend.session.service + +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import onku.backend.domain.session.Session +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.SessionImage +import onku.backend.domain.session.repository.SessionImageRepository +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.service.SessionNoticeService +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class SessionNoticeServiceTest { + + @MockK lateinit var sessionRepository: SessionRepository + @MockK lateinit var sessionImageRepository: SessionImageRepository + + lateinit var sessionNoticeService: SessionNoticeService + + @BeforeEach + fun setUp() { + sessionNoticeService = SessionNoticeService( + sessionRepository = sessionRepository, + sessionImageRepository = sessionImageRepository + ) + } + + private fun createSession( + id: Long = 1L, + detail: SessionDetail? = null + ): Session { + val s = mockk(relaxed = true) + every { s.id } returns id + every { s.sessionDetail } returns detail + return s + } + + private fun createDetail( + id: Long = 10L + ): SessionDetail { + val d = mockk(relaxed = true) + every { d.id } returns id + return d + } + + // 정상 케이스 + @Test + fun `getSessionWithImages - 세션과 디테일, 이미지가 있으면 Triple을 반환한다`() { + val sessionId = 1L + val detailId = 10L + + val detail = createDetail(detailId) + val session = createSession(id = sessionId, detail = detail) + + val images = listOf( + SessionImage(id = 100L, sessionDetail = detail, url = "SESSION/1/A/img1.png"), + SessionImage(id = 101L, sessionDetail = detail, url = "SESSION/1/A/img2.png"), + ) + + every { sessionRepository.findWithDetail(sessionId) } returns session + every { sessionImageRepository.findByDetailId(detailId) } returns images + + // when + val (returnedSession, returnedDetail, returnedImages) = + sessionNoticeService.getSessionWithImages(sessionId) + + // then + assertSame(session, returnedSession) + assertSame(detail, returnedDetail) + assertEquals(2, returnedImages.size) + assertEquals(images, returnedImages) + + verify(exactly = 1) { sessionRepository.findWithDetail(sessionId) } + verify(exactly = 1) { sessionImageRepository.findByDetailId(detailId) } + } + + // 세션이 없을 때 + @Test + fun `getSessionWithImages - 세션이 없으면 SESSION_NOT_FOUND 예외`() { + val sessionId = 99L + + every { sessionRepository.findWithDetail(sessionId) } returns null + + val ex = assertThrows { + sessionNoticeService.getSessionWithImages(sessionId) + } + + assertEquals(SessionErrorCode.SESSION_NOT_FOUND, ex.errorCode) + verify(exactly = 1) { sessionRepository.findWithDetail(sessionId) } + verify(exactly = 0) { sessionImageRepository.findByDetailId(any()) } + } + + // 디테일이 없을 때 + @Test + fun `getSessionWithImages - sessionDetail이 없으면 SESSION_DETAIL_NOT_FOUND 예외`() { + val sessionId = 1L + val session = createSession(id = sessionId, detail = null) + + every { sessionRepository.findWithDetail(sessionId) } returns session + + val ex = assertThrows { + sessionNoticeService.getSessionWithImages(sessionId) + } + + assertEquals(SessionErrorCode.SESSION_DETAIL_NOT_FOUND, ex.errorCode) + verify(exactly = 1) { sessionRepository.findWithDetail(sessionId) } + verify(exactly = 0) { sessionImageRepository.findByDetailId(any()) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/session/service/SessionServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionServiceTest.kt new file mode 100644 index 0000000..734e0b2 --- /dev/null +++ b/src/test/kotlin/onku/backend/session/service/SessionServiceTest.kt @@ -0,0 +1,391 @@ +package onku.backend.session.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import onku.backend.domain.absence.repository.AbsenceReportRepository +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.session.Session +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.dto.request.SessionSaveRequest +import onku.backend.domain.session.dto.response.ThisWeekSessionInfo +import onku.backend.domain.session.enums.SessionCategory +import onku.backend.domain.session.repository.SessionDetailRepository +import onku.backend.domain.session.repository.SessionImageRepository +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.service.SessionService +import onku.backend.domain.session.validator.SessionValidator +import onku.backend.global.exception.CustomException +import onku.backend.global.s3.service.S3Service +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.repository.findByIdOrNull +import java.time.* +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class SessionServiceTest { + + @MockK lateinit var sessionRepository: SessionRepository + @MockK lateinit var sessionValidator: SessionValidator + @MockK lateinit var sessionImageRepository: SessionImageRepository + @MockK lateinit var s3Service: S3Service + @MockK lateinit var attendanceRepository: AttendanceRepository + @MockK lateinit var absenceReportRepository: AbsenceReportRepository + @MockK lateinit var sessionDetailRepository: SessionDetailRepository + + lateinit var clock: Clock + lateinit var sessionService: SessionService + + @BeforeEach + fun setUp() { + clock = Clock.fixed( + Instant.parse("2025-01-01T01:00:00Z"), // Asia/Seoul → 2025-01-01T10:00 + ZoneId.of("Asia/Seoul") + ) + + sessionService = SessionService( + sessionRepository = sessionRepository, + sessionValidator = sessionValidator, + sessionImageRepository = sessionImageRepository, + s3Service = s3Service, + clock = clock, + attendanceRepository = attendanceRepository, + absenceReportRepository = absenceReportRepository, + sessionDetailRepository = sessionDetailRepository + ) + } + + private fun createSession( + id: Long = 1L, + title: String = "세션", + startDate: LocalDate = LocalDate.of(2025, 1, 5), + week: Long = 1L, + category: SessionCategory = SessionCategory.MEETUP_PROJECT, + isHoliday: Boolean = false, + detail: SessionDetail? = null + ): Session { + val s = mockk(relaxed = true) + every { s.id } returns id + every { s.title } returns title + every { s.startDate } returns startDate + every { s.week } returns week + every { s.category } returns category + every { s.isHoliday } returns isHoliday + every { s.sessionDetail } returns detail + return s + } + + // getUpcomingSessionsForAbsence + @Test + fun `getUpcomingSessionsForAbsence - 세션 목록과 active 여부를 반환한다`() { + val now = LocalDateTime.now(clock) // 2025-01-01T10:00 + val today = now.toLocalDate() // 2025-01-01 + + val s1 = createSession(id = 1L, title = "1주차", startDate = today, week = 1L) + val s2 = createSession(id = 2L, title = "2주차", startDate = today.plusDays(1), week = 2L) + + every { sessionRepository.findUpcomingSessions(today) } returns listOf(s1, s2) + every { sessionValidator.isImminentSession(s1, now) } returns true + every { sessionValidator.isImminentSession(s2, now) } returns false + + val result = sessionService.getUpcomingSessionsForAbsence() + + assertEquals(2, result.size) + + val r1 = result[0] + assertEquals(1L, r1.sessionId) + assertEquals("1주차", r1.title) + assertEquals(1L, r1.week) + assertEquals(true, r1.active) + + val r2 = result[1] + assertEquals(2L, r2.sessionId) + assertEquals("2주차", r2.title) + assertEquals(2L, r2.week) + assertEquals(false, r2.active) + + verify { sessionRepository.findUpcomingSessions(today) } + verify { sessionValidator.isImminentSession(s1, now) } + verify { sessionValidator.isImminentSession(s2, now) } + } + + // getById + @Test + fun `getById - 세션이 있으면 반환한다`() { + val session = createSession(id = 10L) + every { sessionRepository.findByIdOrNull(10L) } returns session + + val result = sessionService.getById(10L) + + assertSame(session, result) + } + + @Test + fun `getById - 세션이 없으면 예외 던진다`() { + every { sessionRepository.findByIdOrNull(10L) } returns null + + val ex = assertThrows { + sessionService.getById(10L) + } + assertEquals(SessionErrorCode.SESSION_NOT_FOUND, ex.errorCode) + } + + // saveAll + @Test + fun `saveAll - 요청 리스트를 Session 엔티티로 저장한다`() { + val requests = listOf( + SessionSaveRequest( + title = "세션1", + sessionDate = LocalDate.of(2025, 1, 10), + category = SessionCategory.MEETUP_PROJECT, + week = 1L, + isHoliday = false + ), + SessionSaveRequest( + title = "세션2", + sessionDate = LocalDate.of(2025, 1, 17), + category = SessionCategory.MEETUP_PROJECT, + week = 2L, + isHoliday = true + ) + ) + + every { sessionRepository.saveAll(any>()) } answers { + firstArg>().toList() + } + + val result = sessionService.saveAll(requests) + assertTrue(result) + + val slot = slot>() + verify { + sessionRepository.saveAll(capture(slot)) + } + + val saved = slot.captured.toList() + assertEquals(2, saved.size) + assertEquals("세션1", saved[0].title) + assertEquals(LocalDate.of(2025, 1, 10), saved[0].startDate) + assertEquals(1L, saved[0].week) + assertFalse(saved[0].isHoliday) + + assertEquals("세션2", saved[1].title) + assertEquals(LocalDate.of(2025, 1, 17), saved[1].startDate) + assertEquals(2L, saved[1].week) + assertTrue(saved[1].isHoliday) + } + + // getInitialSession + @Test + fun `getInitialSession - 페이지로 세션을 조회하고 DTO로 매핑한다`() { + val s1 = createSession( + id = 1L, + title = "세션1", + startDate = LocalDate.of(2025, 1, 10), + category = SessionCategory.MEETUP_PROJECT, + isHoliday = false, + detail = mockk(relaxed = true) { + every { id } returns 100L + } + ) + val s2 = createSession( + id = 2L, + title = "세션2", + startDate = LocalDate.of(2025, 1, 17), + category = SessionCategory.MEETUP_PROJECT, + isHoliday = true, + detail = null + ) + + val pageable = PageRequest.of(0, 10) + val page: Page = PageImpl(listOf(s1, s2), pageable, 2) + + every { sessionRepository.findAll(pageable) } returns page + + val resultPage = sessionService.getInitialSession(pageable) + + assertEquals(2, resultPage.totalElements) + val dto1 = resultPage.content[0] + assertEquals(1L, dto1.sessionId) + assertEquals(LocalDate.of(2025, 1, 10), dto1.startDate) + assertEquals("세션1", dto1.title) + assertEquals(SessionCategory.MEETUP_PROJECT, dto1.category) + assertEquals(100L, dto1.sessionDetailId) + assertFalse(dto1.isHoliday) + + val dto2 = resultPage.content[1] + assertEquals(2L, dto2.sessionId) + assertEquals(LocalDate.of(2025, 1, 17), dto2.startDate) + assertEquals("세션2", dto2.title) + assertEquals(SessionCategory.MEETUP_PROJECT, dto2.category) + assertNull(dto2.sessionDetailId) + assertTrue(dto2.isHoliday) + } + + // getThisWeekSession + @Test + fun `getThisWeekSession - 이번주 세션 정보를 매핑해서 반환한다`() { + val projection = mockk() + every { projection.sessionId } returns 1L + every { projection.sessionDetailId } returns 10L + every { projection.title } returns "이번주 세션" + every { projection.place } returns "강의실 101" + every { projection.startDate } returns LocalDate.of(2025, 1, 5) + every { projection.startTime } returns LocalTime.of(10, 0) + every { projection.endTime } returns LocalTime.of(12, 0) + every { projection.isHoliday } returns false + + every { sessionRepository.findThisWeekSunToSat(any(), any()) } returns listOf(projection) + + val result = sessionService.getThisWeekSession() + + assertEquals(1, result.size) + val dto = result[0] + assertEquals(1L, dto.sessionId) + assertEquals(10L, dto.sessionDetailId) + assertEquals("이번주 세션", dto.title) + assertEquals("강의실 101", dto.place) + assertEquals(LocalDate.of(2025, 1, 5), dto.startDate) + assertEquals(LocalTime.of(10, 0), dto.startTime) + assertEquals(LocalTime.of(12, 0), dto.endTime) + + verify { sessionRepository.findThisWeekSunToSat(any(), any()) } + } + + // getAllSessionsOrderByStartDate + @Test + fun `getAllSessionsOrderByStartDate - 모든 세션을 카드 정보로 반환한다`() { + val s1 = createSession( + id = 1L, + title = "세션1", + startDate = LocalDate.of(2025, 1, 3), + category = SessionCategory.MEETUP_PROJECT, + isHoliday = false + ) + val s2 = createSession( + id = 2L, + title = "세션2", + startDate = LocalDate.of(2025, 1, 10), + category = SessionCategory.MEETUP_PROJECT, + isHoliday = true + ) + + every { sessionRepository.findAllSessionsOrderByStartDate() } returns listOf(s1, s2) + + val result = sessionService.getAllSessionsOrderByStartDate() + + assertEquals(2, result.size) + val card1 = result[0] + assertEquals(1L, card1.sessionId) + assertEquals(SessionCategory.MEETUP_PROJECT, card1.sessionCategory) + assertEquals("세션1", card1.title) + assertEquals(LocalDate.of(2025, 1, 3), card1.startDate) + + val card2 = result[1] + assertEquals(2L, card2.sessionId) + assertEquals(SessionCategory.MEETUP_PROJECT, card2.sessionCategory) + assertEquals("세션2", card2.title) + assertEquals(LocalDate.of(2025, 1, 10), card2.startDate) + } + + // deleteCascade + + @Test + fun `deleteCascade - 세션이 없으면 예외`() { + every { sessionRepository.findWithDetail(1L) } returns null + + val ex = assertThrows { + sessionService.deleteCascade(1L) + } + + assertEquals(SessionErrorCode.SESSION_NOT_FOUND, ex.errorCode) + verify(exactly = 0) { attendanceRepository.deleteAllBySessionId(any()) } + verify(exactly = 0) { sessionRepository.deleteById(any()) } + } + + @Test + fun `deleteCascade - detailId가 없으면 attendance, absence만 지우고 세션 삭제`() { + every { sessionRepository.findWithDetail(1L) } returns createSession(id = 1L) + every { sessionRepository.findDetailIdBySessionId(1L) } returns null + + every { attendanceRepository.deleteAllBySessionId(1L) } returns 1 + every { absenceReportRepository.deleteAllBySessionId(1L) } returns 1 + every { sessionRepository.deleteById(1L) } just Runs + + sessionService.deleteCascade(1L) + + verify { attendanceRepository.deleteAllBySessionId(1L) } + verify { absenceReportRepository.deleteAllBySessionId(1L) } + verify(exactly = 0) { sessionImageRepository.findAllImageKeysByDetailId(any()) } + verify(exactly = 0) { sessionDetailRepository.deleteById(any()) } + verify { sessionRepository.deleteById(1L) } + } + + @Test + fun `deleteCascade - detailId가 있고 이미지가 있으면 S3와 이미지, detail, session을 차례로 삭제`() { + val sessionId = 1L + val detailId = 10L + + every { sessionRepository.findWithDetail(sessionId) } returns createSession(id = sessionId) + every { sessionRepository.findDetailIdBySessionId(sessionId) } returns detailId + + every { attendanceRepository.deleteAllBySessionId(sessionId) } returns 1 + every { absenceReportRepository.deleteAllBySessionId(sessionId) } returns 1 + + every { sessionImageRepository.findAllImageKeysByDetailId(detailId) } returns listOf( + "", + "SESSION/1/A/img1.png", + "SESSION/1/A/img2.png" + ) + + every { s3Service.deleteObjectsNow(listOf("SESSION/1/A/img1.png", "SESSION/1/A/img2.png")) } just Runs + every { sessionImageRepository.deleteByDetailIdBulk(detailId) } returns 1 + every { sessionRepository.detachDetailFromSession(sessionId) } returns 1 + every { sessionDetailRepository.deleteById(detailId) } just Runs + every { sessionRepository.deleteById(sessionId) } just Runs + + sessionService.deleteCascade(sessionId) + + verify { attendanceRepository.deleteAllBySessionId(sessionId) } + verify { absenceReportRepository.deleteAllBySessionId(sessionId) } + verify { sessionImageRepository.findAllImageKeysByDetailId(detailId) } + verify { + s3Service.deleteObjectsNow(listOf("SESSION/1/A/img1.png", "SESSION/1/A/img2.png")) + } + verify { sessionImageRepository.deleteByDetailIdBulk(detailId) } + verify { sessionRepository.detachDetailFromSession(sessionId) } + verify { sessionDetailRepository.deleteById(detailId) } + verify { sessionRepository.deleteById(sessionId) } + } + + // getByDetailIdFetchDetail + + @Test + fun `getByDetailIdFetchDetail - 세션이 있으면 반환`() { + val session = createSession(id = 1L) + every { sessionRepository.findByDetailIdFetchDetail(10L) } returns session + + val result = sessionService.getByDetailIdFetchDetail(10L) + + assertSame(session, result) + } + + @Test + fun `getByDetailIdFetchDetail - 세션 없으면 예외`() { + every { sessionRepository.findByDetailIdFetchDetail(10L) } returns null + + val ex = assertThrows { + sessionService.getByDetailIdFetchDetail(10L) + } + + assertEquals(SessionErrorCode.SESSION_NOT_FOUND, ex.errorCode) + } +} \ No newline at end of file