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
+
+
+## 🛠️ 기술 스택
+| 기술 스택 | 사용 이유 |
+|----------|-----------|
+| **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