From d24f3f0530a3819c6376ceca64f4c53cd67b426e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 2 Oct 2025 15:44:30 +0900 Subject: [PATCH 001/470] =?UTF-8?q?chore=20:=20yml=20gitIgnore=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 52709b9..7efa193 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ out/ .kotlin ### YAML ### -application.yml \ No newline at end of file +application.yml +src/main/resources/* \ No newline at end of file From 561ee5f119d19cf6ca42c8910d9fb1f96a9cf587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 2 Oct 2025 15:44:43 +0900 Subject: [PATCH 002/470] =?UTF-8?q?chore=20:=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=9E=91=EC=84=B1=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 77 ++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b04ef4e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,77 @@ +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: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "temurin" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build + run: | + chmod +x gradlew + ./gradlew clean build + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Set image tag + id: vars + run: echo "tag=${GITHUB_SHA}" >> $GITHUB_OUTPUT + + - name: Build & Push image + run: | + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/store-group:${{ steps.vars.outputs.tag }} \ + -t ${{ secrets.DOCKERHUB_USERNAME }}/store-group:latest . + docker push ${{ secrets.DOCKERHUB_USERNAME }}/store-group:${{ steps.vars.outputs.tag }} + docker push ${{ secrets.DOCKERHUB_USERNAME }}/store-group:latest + deploy-dev: + if: github.ref == 'refs/heads/dev' + needs: build-and-push + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Create dev config from secrets + run: | + mkdir -p config + printf "%s" "${{ secrets.ONKU_APP_DEV_YML }}" > config/application-dev.yml + chmod 600 config/application-dev.yml + + - name: Upload files to DEV EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + source: ./config/application-dev.yml,./docker-compose.yml + target: /home/ubuntu/deploy/ + + - name: Remote deploy (DEV) + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + port: ${{ secrets.DEV_EC2_PORT }} + script: | + export DOCKER_CONTAINER_REGISTRY=${{ secrets.DOCKERHUB_USERNAME }} + export GITHUB_SHA=${{ github.sha }} + sudo chmod +x ./deploy/deploy.sh + ./deploy/deploy.sh + chmod 600 ./deploy/deploy.sh \ No newline at end of file From 100a4395afdcfd4a70bfb7e3f0e21c4ca5829ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 2 Oct 2025 15:44:56 +0900 Subject: [PATCH 003/470] =?UTF-8?q?feat=20:=20=EB=8F=84=EC=BB=A4=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 6 ++++++ docker-compose.yml | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1412a04 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM openjdk:17.0.1-jdk-slim +WORKDIR /app +COPY ./build/libs/onku-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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..459a0ac --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: "3.8" + +services: + server: + image: "${DOCKER_CONTAINER_REGISTRY}/onku:${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 + ports: + - '6379:6379' + volumes: + - redis-data:/data + networks: + - onku-network + +volumes: + redis-data: + +networks: + onku-network: + driver: bridge \ No newline at end of file From d1edb9483a3c3c8db4d746f919f94a89dc6d9fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 2 Oct 2025 16:12:51 +0900 Subject: [PATCH 004/470] =?UTF-8?q?chore=20:=20issue=20template=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20#3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../\342\231\273\357\270\217-refactor.md" | 17 +++++++++++++++++ .../\342\232\231\357\270\217-setting.md" | 17 +++++++++++++++++ ".github/ISSUE_TEMPLATE/\342\234\205-test.md" | 17 +++++++++++++++++ .../ISSUE_TEMPLATE/\342\234\250-feature.md" | 17 +++++++++++++++++ .../ISSUE_TEMPLATE/\360\237\220\233-fix.md" | 17 +++++++++++++++++ 5 files changed, 85 insertions(+) create mode 100644 ".github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" create mode 100644 ".github/ISSUE_TEMPLATE/\342\232\231\357\270\217-setting.md" create mode 100644 ".github/ISSUE_TEMPLATE/\342\234\205-test.md" create mode 100644 ".github/ISSUE_TEMPLATE/\342\234\250-feature.md" create mode 100644 ".github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" 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 From ddea2084accff2a5831f20fa5de1bd3bff5dc2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 2 Oct 2025 16:14:37 +0900 Subject: [PATCH 005/470] =?UTF-8?q?chore=20:=20pr=20template=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80=20#3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/pull_request_template.md 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) + From 198f47f4d366e52e769b68a6fb39c4cacee8f5ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 2 Oct 2025 19:40:51 +0900 Subject: [PATCH 006/470] =?UTF-8?q?feat=20:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EA=B5=AC=EC=B6=95=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 ++ .../onku/backend/config/SwaggerConfig.kt | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/main/kotlin/onku/backend/config/SwaggerConfig.kt diff --git a/build.gradle.kts b/build.gradle.kts index d037b4f..914ea3c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,8 @@ dependencies { 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") } kotlin { diff --git a/src/main/kotlin/onku/backend/config/SwaggerConfig.kt b/src/main/kotlin/onku/backend/config/SwaggerConfig.kt new file mode 100644 index 0000000..a3a4857 --- /dev/null +++ b/src/main/kotlin/onku/backend/config/SwaggerConfig.kt @@ -0,0 +1,36 @@ +package onku.backend.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 From 2aff91b89d85393a098bd87409e10e79e292b13a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 2 Oct 2025 19:42:20 +0900 Subject: [PATCH 007/470] =?UTF-8?q?feat=20:=20=ED=97=AC=EC=8A=A4=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/test/TestController.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/kotlin/onku/backend/test/TestController.kt diff --git a/src/main/kotlin/onku/backend/test/TestController.kt b/src/main/kotlin/onku/backend/test/TestController.kt new file mode 100644 index 0000000..ab5d419 --- /dev/null +++ b/src/main/kotlin/onku/backend/test/TestController.kt @@ -0,0 +1,17 @@ +package onku.backend.test + +import io.swagger.v3.oas.annotations.Operation +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +class TestController { + @ResponseStatus(HttpStatus.OK) + @GetMapping("/health") + @Operation(summary = "헬스 체크", description = "health") + fun health(): String { + return "OK" + } +} \ No newline at end of file From 27cd8677383230b49eca7051786a2b226583200f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 2 Oct 2025 20:15:27 +0900 Subject: [PATCH 008/470] =?UTF-8?q?fix=20:=20=EB=B0=B0=ED=8F=AC=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=88=98=EC=A0=95=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 111 ++++++++++++++++++++++------------- 1 file changed, 70 insertions(+), 41 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b04ef4e..6364171 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,38 +9,30 @@ jobs: outputs: tag: ${{ steps.vars.outputs.tag }} steps: - - uses: actions/checkout@v4 + - name: Checkout source code + uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - name: Set up SSH key + uses: webfactory/ssh-agent@v0.5.3 with: - java-version: "17" - distribution: "temurin" + dev-ssh-private-key: ${{ secrets.DEV_EC2_SSH_KEY }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - - - name: Build - run: | - chmod +x gradlew - ./gradlew clean build - - - name: Login to Docker Hub - uses: docker/login-action@v3 + - name: Gradle 캐시 적용 + uses: actions/cache@v3 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle - - name: Set image tag - id: vars - run: echo "tag=${GITHUB_SHA}" >> $GITHUB_OUTPUT + - name: JDK 17 세팅 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' - - name: Build & Push image - run: | - docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/store-group:${{ steps.vars.outputs.tag }} \ - -t ${{ secrets.DOCKERHUB_USERNAME }}/store-group:latest . - docker push ${{ secrets.DOCKERHUB_USERNAME }}/store-group:${{ steps.vars.outputs.tag }} - docker push ${{ secrets.DOCKERHUB_USERNAME }}/store-group:latest deploy-dev: if: github.ref == 'refs/heads/dev' needs: build-and-push @@ -48,30 +40,67 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Create dev config from secrets + - name: YML 파일 세팅 + env: + APPLICATION_PROPERTIES: ${{ secrets.ONKU_APP_DEV_YML }} + TEST_APPLICATION_PROPERTIES: ${{ secrets.ONKU_APP_TEST_YML }} + run: | - mkdir -p config - printf "%s" "${{ secrets.ONKU_APP_DEV_YML }}" > config/application-dev.yml - chmod 600 config/application-dev.yml + cd ./src + rm -rf main/resources/application.yml + mkdir -p main/resources + mkdir -p test/resources + echo "$APPLICATION_PROPERTIES" > main/resources/application.yml + echo "$TEST_APPLICATION_PROPERTIES" > test/resources/application.yml + + - name: gradlew 권한 부여 + run: chmod +x gradlew + + - name: 테스트 수행 + run: ./gradlew test - - name: Upload files to DEV EC2 - uses: appleboy/scp-action@master + - name: 테스트 리포트 아티팩트 업로드 + if: failure() + uses: actions/upload-artifact@v4 with: - host: ${{ secrets.DEV_EC2_HOST }} - username: ${{ secrets.DEV_EC2_USER }} - key: ${{ secrets.DEV_EC2_SSH_KEY }} - source: ./config/application-dev.yml,./docker-compose.yml - target: /home/ubuntu/deploy/ + name: test-report + path: build/reports/tests/test + + - name: 스프링부트 빌드 + run: ./gradlew build - - name: Remote deploy (DEV) + - 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 }}/onku:${{ 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 GITHUB_SHA=${{ github.sha }} - sudo chmod +x ./deploy/deploy.sh - ./deploy/deploy.sh - chmod 600 ./deploy/deploy.sh \ No newline at end of file + sudo chmod +x ./deploy.sh + ./deploy.sh \ No newline at end of file From 78b338d4a8822730680afd76d88e289e980c99ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 2 Oct 2025 20:15:49 +0900 Subject: [PATCH 009/470] =?UTF-8?q?chore=20:=20h2=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 914ea3c..5ba280e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,8 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") // swagger implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0") + // h2 + runtimeOnly("com.h2database:h2") } kotlin { From 9c5431610fec0903754cf20a0e9700309a385dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 2 Oct 2025 20:18:17 +0900 Subject: [PATCH 010/470] =?UTF-8?q?fix=20:=20key=EA=B0=92=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=20=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6364171..4343eef 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,14 +8,21 @@ jobs: runs-on: ubuntu-latest outputs: tag: ${{ steps.vars.outputs.tag }} + + 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: - dev-ssh-private-key: ${{ secrets.DEV_EC2_SSH_KEY }} + ssh-private-key: ${{ secrets.DEV_EC2_SSH_KEY }} - name: Gradle 캐시 적용 uses: actions/cache@v3 @@ -33,13 +40,6 @@ jobs: java-version: '17' distribution: 'temurin' - deploy-dev: - if: github.ref == 'refs/heads/dev' - needs: build-and-push - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: YML 파일 세팅 env: APPLICATION_PROPERTIES: ${{ secrets.ONKU_APP_DEV_YML }} From 9211ab3947922748fe6e19045b3a0387c1eee214 Mon Sep 17 00:00:00 2001 From: kimyeoungrok <127182406+kimyeoungrok@users.noreply.github.com> Date: Thu, 2 Oct 2025 20:22:09 +0900 Subject: [PATCH 011/470] Update deploy.yml --- .github/workflows/deploy.yml | 38 +++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4343eef..ccbe7d1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,23 +8,16 @@ jobs: runs-on: ubuntu-latest outputs: tag: ${{ steps.vars.outputs.tag }} - - 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 + - name: JDK 17 세팅 + uses: actions/setup-java@v4 with: - ssh-private-key: ${{ secrets.DEV_EC2_SSH_KEY }} + java-version: '17' + distribution: 'temurin' - - name: Gradle 캐시 적용 + - name: Gradle 캐시 uses: actions/cache@v3 with: path: | @@ -34,12 +27,21 @@ jobs: restore-keys: | ${{ runner.os }}-gradle - - name: JDK 17 세팅 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' + 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 }} @@ -103,4 +105,4 @@ jobs: export DOCKER_CONTAINER_REGISTRY=${{ secrets.DOCKERHUB_USERNAME }} export GITHUB_SHA=${{ github.sha }} sudo chmod +x ./deploy.sh - ./deploy.sh \ No newline at end of file + ./deploy.sh From d53d7d4b9dc02a469faf666e2796b0eb020084c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 2 Oct 2025 20:27:14 +0900 Subject: [PATCH 012/470] =?UTF-8?q?fix=20:=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9D=B4=EB=A6=84=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4343eef..f4d5880 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -85,7 +85,7 @@ jobs: context: . file: ./Dockerfile push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/onku:${{ github.sha }} + tags: ${{ secrets.DOCKERHUB_USERNAME }}/backend:${{ github.sha }} platforms: linux/amd64,linux/arm64 - name: Docker Compose 파일 NCP 서버로 전송 diff --git a/Dockerfile b/Dockerfile index 1412a04..e00bc4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM openjdk:17.0.1-jdk-slim WORKDIR /app -COPY ./build/libs/onku-0.0.1-SNAPSHOT.jar /app/backend.jar +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 From f35221bd312be0794adf82f89f2984d2ce2d46c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 2 Oct 2025 20:50:04 +0900 Subject: [PATCH 013/470] =?UTF-8?q?fix=20:=20=EB=8F=84=EC=BB=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=9D=B4=EB=A6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 459a0ac..78b6858 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.8" services: server: - image: "${DOCKER_CONTAINER_REGISTRY}/onku:${GITHUB_SHA}" + image: "${DOCKER_CONTAINER_REGISTRY}/backend:${GITHUB_SHA}" container_name: onku environment: TZ: Asia/Seoul From 5fe56d0c121af22e459b87bf388ea0f903c93960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 3 Oct 2025 15:30:35 +0900 Subject: [PATCH 014/470] =?UTF-8?q?feat=20:=20=EB=A0=88=EB=94=94=EC=8A=A4?= =?UTF-8?q?=20=ED=8C=A8=EC=8A=A4=EC=9B=8C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 1 + docker-compose.yml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 69f87f0..ce154b6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -103,6 +103,7 @@ jobs: 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 diff --git a/docker-compose.yml b/docker-compose.yml index 78b6858..694c02d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,12 +15,15 @@ services: 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: From a179000db349f649b6ed6f8fe788cf22705b1461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 3 Oct 2025 15:51:45 +0900 Subject: [PATCH 015/470] =?UTF-8?q?feat=20:=20prod=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 80 ++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ce154b6..6c3910d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -107,3 +107,83 @@ jobs: 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 }} + + run: | + cd ./src + rm -rf main/resources/application.yml + mkdir -p main/resources + mkdir -p test/resources + echo "$APPLICATION_PROPERTIES" > main/resources/application.yml + echo "$TEST_APPLICATION_PROPERTIES" > test/resources/application.yml + + - 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 \ No newline at end of file From e3740d5ad4a0de9de97548696c200c8ca3ed0c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 3 Oct 2025 18:14:26 +0900 Subject: [PATCH 016/470] =?UTF-8?q?chore=20:=20global,=20domain=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=EC=9D=98=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/{ => domain}/test/TestController.kt | 2 +- .../kotlin/onku/backend/{ => global}/config/SwaggerConfig.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/main/kotlin/onku/backend/{ => domain}/test/TestController.kt (93%) rename src/main/kotlin/onku/backend/{ => global}/config/SwaggerConfig.kt (96%) diff --git a/src/main/kotlin/onku/backend/test/TestController.kt b/src/main/kotlin/onku/backend/domain/test/TestController.kt similarity index 93% rename from src/main/kotlin/onku/backend/test/TestController.kt rename to src/main/kotlin/onku/backend/domain/test/TestController.kt index ab5d419..726aac5 100644 --- a/src/main/kotlin/onku/backend/test/TestController.kt +++ b/src/main/kotlin/onku/backend/domain/test/TestController.kt @@ -1,4 +1,4 @@ -package onku.backend.test +package onku.backend.domain.test import io.swagger.v3.oas.annotations.Operation import org.springframework.http.HttpStatus diff --git a/src/main/kotlin/onku/backend/config/SwaggerConfig.kt b/src/main/kotlin/onku/backend/global/config/SwaggerConfig.kt similarity index 96% rename from src/main/kotlin/onku/backend/config/SwaggerConfig.kt rename to src/main/kotlin/onku/backend/global/config/SwaggerConfig.kt index a3a4857..4a2adaa 100644 --- a/src/main/kotlin/onku/backend/config/SwaggerConfig.kt +++ b/src/main/kotlin/onku/backend/global/config/SwaggerConfig.kt @@ -1,4 +1,4 @@ -package onku.backend.config +package onku.backend.global.config import io.swagger.v3.oas.models.Components import io.swagger.v3.oas.models.OpenAPI From 1dcfa33fb1d828bf5df682973cb29f55287d7bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 3 Oct 2025 19:23:03 +0900 Subject: [PATCH 017/470] =?UTF-8?q?chore=20:=20validation=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle.kts b/build.gradle.kts index 5ba280e..9d490cd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,7 @@ 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.jetbrains.kotlin:kotlin-reflect") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.springframework.boot:spring-boot-starter-test") From 9c9c98e3cb4bf2300510ab40ddd51d8801829cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 3 Oct 2025 19:23:53 +0900 Subject: [PATCH 018/470] =?UTF-8?q?feat=20:=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B7=9C=EA=B2=A9=ED=99=94=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/response/ErrorResponse.kt | 25 ++++++++++++++++++ .../global/response/SuccessResponse.kt | 26 +++++++++++++++++++ .../global/response/result/ExceptionResult.kt | 25 ++++++++++++++++++ .../global/response/result/ResponseState.kt | 9 +++++++ 4 files changed, 85 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/response/ErrorResponse.kt create mode 100644 src/main/kotlin/onku/backend/global/response/SuccessResponse.kt create mode 100644 src/main/kotlin/onku/backend/global/response/result/ExceptionResult.kt create mode 100644 src/main/kotlin/onku/backend/global/response/result/ResponseState.kt diff --git a/src/main/kotlin/onku/backend/global/response/ErrorResponse.kt b/src/main/kotlin/onku/backend/global/response/ErrorResponse.kt new file mode 100644 index 0000000..473a382 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/response/ErrorResponse.kt @@ -0,0 +1,25 @@ +package onku.backend.global.response + +import io.swagger.v3.oas.annotations.media.Schema + +class ErrorResponse( + @Schema(description = "예외 코드", example = "COMMON500") + val code: String, + + @Schema(description = "예외 메세지", example = "실패하였습니다.") + val message: String, + + @Schema(description = "예외 참고 데이터") + val result: T? = null +) { + @Schema(description = "성공 여부", example = "false") + val isSuccess: Boolean = false + + companion object { + fun of(code: String, message: String): ErrorResponse = + ErrorResponse(code = code, message = message, result = null) + + fun ok(code: String, message: String, data: T?): ErrorResponse = + ErrorResponse(code = code, message = message, result = data) + } +} diff --git a/src/main/kotlin/onku/backend/global/response/SuccessResponse.kt b/src/main/kotlin/onku/backend/global/response/SuccessResponse.kt new file mode 100644 index 0000000..aa69dbb --- /dev/null +++ b/src/main/kotlin/onku/backend/global/response/SuccessResponse.kt @@ -0,0 +1,26 @@ +package onku.backend.global.response + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.global.response.result.ResponseState + +class SuccessResponse( + @Schema(description = "상태 코드", example = "1") + val code: Int, + + @Schema(description = "응답 메세지", example = "성공하였습니다.") + val message: String, + + @Schema(description = "응답 데이터") + val result: T +) { + @Schema(description = "성공 여부", example = "true") + val isSuccess: Boolean = true + + companion object { + fun of(code: Int, message: String, data: T): SuccessResponse = + SuccessResponse(code = code, message = message, result = data) + + fun ok(data: T): SuccessResponse = + of(ResponseState.SUCCESS.code, ResponseState.SUCCESS.message, data) + } +} diff --git a/src/main/kotlin/onku/backend/global/response/result/ExceptionResult.kt b/src/main/kotlin/onku/backend/global/response/result/ExceptionResult.kt new file mode 100644 index 0000000..36b6376 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/response/result/ExceptionResult.kt @@ -0,0 +1,25 @@ +package onku.backend.global.response.result + +import io.swagger.v3.oas.annotations.media.Schema + +class ExceptionResult { + + data class ServerErrorData( + @Schema(description = "오류 발생 클래스", example = "org.example.XX") + val errorClass: String? = null, + + @Schema(description = "오류 메세지") + val errorMessage: String? = null + ) + + data class ParameterData( + @Schema(description = "오류가 발생한 필드", example = "title") + val key: String? = null, + + @Schema(description = "넣은 요청값", example = "null") + val value: String? = null, + + @Schema(description = "오류 발생 이유", example = "공백일 수 없습니다") + val reason: String? = null + ) +} diff --git a/src/main/kotlin/onku/backend/global/response/result/ResponseState.kt b/src/main/kotlin/onku/backend/global/response/result/ResponseState.kt new file mode 100644 index 0000000..2604964 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/response/result/ResponseState.kt @@ -0,0 +1,9 @@ +package onku.backend.global.response.result + +enum class ResponseState( + val code: Int, + val message: String +) { + SUCCESS(1, "성공하였습니다."), + FAIL(-1, "실패하였습니다."); +} From 04a87511295236a833419bd02c30b9bdf4303196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 3 Oct 2025 19:24:20 +0900 Subject: [PATCH 019/470] =?UTF-8?q?feat=20:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=A7=81=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/exception/ApiErrorCode.kt | 9 ++ .../global/exception/CustomException.kt | 5 + .../backend/global/exception/ErrorCode.kt | 17 +++ .../global/exception/ExceptionAdvice.kt | 122 ++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/exception/ApiErrorCode.kt create mode 100644 src/main/kotlin/onku/backend/global/exception/CustomException.kt create mode 100644 src/main/kotlin/onku/backend/global/exception/ErrorCode.kt create mode 100644 src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt 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..9f30088 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -0,0 +1,17 @@ +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); +} 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..4edefa9 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt @@ -0,0 +1,122 @@ +package onku.backend.global.exception + +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.ConstraintViolationException +import onku.backend.global.response.ErrorResponse +import onku.backend.global.response.result.ExceptionResult +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 { + + /** + * 등록되지 않은 에러 + */ + @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) + } +} From 79697bc7ee37f7e9f73ed0d6ba485bf9c5f01272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 3 Oct 2025 19:25:22 +0900 Subject: [PATCH 020/470] =?UTF-8?q?chore=20:=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=20=EA=B3=B5=ED=86=B5=EC=9D=91=EB=8B=B5=20=EB=B0=8F=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=A7=81=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=9A=A9=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20[TEMP]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/test/TestController.kt | 46 +++++++++++++++++-- .../onku/backend/domain/test/dto/TestDto.kt | 11 +++++ 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/test/dto/TestDto.kt diff --git a/src/main/kotlin/onku/backend/domain/test/TestController.kt b/src/main/kotlin/onku/backend/domain/test/TestController.kt index 726aac5..8ba1e80 100644 --- a/src/main/kotlin/onku/backend/domain/test/TestController.kt +++ b/src/main/kotlin/onku/backend/domain/test/TestController.kt @@ -1,12 +1,20 @@ package onku.backend.domain.test import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import jakarta.validation.constraints.Min +import onku.backend.domain.test.dto.TestDto +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode +import onku.backend.global.response.SuccessResponse import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* +// TODO: 이후 프론트 연동을 위해 명세서를 전달하기 전에 해당 컨트롤러를 @Hidden으로 숨김처리해서 정리하기 (or 삭제하기) @RestController +@RequestMapping("/test") +@Tag(name = "테스트용 API") class TestController { @ResponseStatus(HttpStatus.OK) @GetMapping("/health") @@ -14,4 +22,36 @@ class TestController { fun health(): String { return "OK" } + + @PostMapping("/request-body") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "body 검증 테스트", description = "TestDto의 count의 @Min 조건 검증") + fun beanValidation(@RequestBody @Valid body: TestDto): SuccessResponse = + SuccessResponse.ok("VALID") + + @GetMapping("/param") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "Param 검증 테스트", description = "@RequestParam의 @Min 조건 검증") + fun paramValidation( + @RequestParam @Min(1, message = "age는 1 이상이어야 합니다") age: Int + ): SuccessResponse = + SuccessResponse.ok("AGE=$age") + + @PostMapping("/json") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "JSON 문법 에러 검증 테스트", description = "콤마 누락 등의 문법오류와 필드의 타입오류 검증") + fun jsonGrammar(@RequestBody body: TestDto): SuccessResponse = + SuccessResponse.ok("PARSED") + + @GetMapping("/custom-error") + @Operation(summary = "커스텀 예외 테스트") + fun custom(): String { + throw CustomException(ErrorCode.INVALID_REQUEST) + } + + @GetMapping("/untracked-error") + @Operation(summary = "미등록 예외 테스트") + fun untracked(): String { + throw IllegalStateException("테스트용 미등록 예외") + } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/test/dto/TestDto.kt b/src/main/kotlin/onku/backend/domain/test/dto/TestDto.kt new file mode 100644 index 0000000..6d139fa --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/test/dto/TestDto.kt @@ -0,0 +1,11 @@ +package onku.backend.domain.test.dto + +import jakarta.validation.constraints.* + +data class TestDto( + @field:NotBlank(message = "title은 공백일 수 없습니다") + val title: String?, + + @field:Min(value = 1, message = "count는 1 이상이어야 합니다") + val count: Int? +) From bab9aa9164e9102f0d8f7f5d6cb29c97b07c866e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:09:41 +0900 Subject: [PATCH 021/470] =?UTF-8?q?chore:=20jwt,=20redis=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 9d490cd..5b4061c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { 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") @@ -34,6 +35,12 @@ dependencies { 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") } kotlin { From 5947fd58fc83a772708b7cda278f7936590943e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:11:45 +0900 Subject: [PATCH 022/470] =?UTF-8?q?chore:=20test=20controller=20api?= =?UTF-8?q?=EB=93=A4=EC=9D=80=20swagger=20=EB=AC=B8=EC=84=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=88=A8=EA=B8=B0=EA=B8=B0=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/test/TestController.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/test/TestController.kt b/src/main/kotlin/onku/backend/domain/test/TestController.kt index 8ba1e80..ae18072 100644 --- a/src/main/kotlin/onku/backend/domain/test/TestController.kt +++ b/src/main/kotlin/onku/backend/domain/test/TestController.kt @@ -1,5 +1,6 @@ package onku.backend.domain.test +import io.swagger.v3.oas.annotations.Hidden import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid @@ -11,7 +12,7 @@ import onku.backend.global.response.SuccessResponse import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.* -// TODO: 이후 프론트 연동을 위해 명세서를 전달하기 전에 해당 컨트롤러를 @Hidden으로 숨김처리해서 정리하기 (or 삭제하기) +@Hidden @RestController @RequestMapping("/test") @Tag(name = "테스트용 API") From b55c3587e6f2573f5f7c8a0d0e40335bf890c935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:12:45 +0900 Subject: [PATCH 023/470] =?UTF-8?q?feat:=20redis=EC=97=90=20refresh=20toke?= =?UTF-8?q?n=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/redis/RefreshTokenCacheUtil.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/redis/RefreshTokenCacheUtil.kt diff --git a/src/main/kotlin/onku/backend/global/redis/RefreshTokenCacheUtil.kt b/src/main/kotlin/onku/backend/global/redis/RefreshTokenCacheUtil.kt new file mode 100644 index 0000000..cbff4e9 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/redis/RefreshTokenCacheUtil.kt @@ -0,0 +1,25 @@ +package onku.backend.global.redis + +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.time.Duration + +@Component +class RefreshTokenCacheUtil( + private val redisTemplate: RedisTemplate +) { + companion object { + private const val PREFIX = "refresh_token:" + } + + fun saveRefreshToken(email: String, refreshToken: String, ttl: Duration) { + redisTemplate.opsForValue().set(PREFIX + email, refreshToken, ttl) + } + + fun getRefreshToken(email: String): String? = + redisTemplate.opsForValue().get(PREFIX + email) + + fun deleteRefreshToken(email: String) { + redisTemplate.delete(PREFIX + email) + } +} From 43d2f1022fa9402787471c8b141d58df772830e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:13:59 +0900 Subject: [PATCH 024/470] =?UTF-8?q?feat:=20=EA=B6=8C=ED=95=9C=EC=9D=84=20r?= =?UTF-8?q?oles=EC=97=90=20=EB=84=A3=EC=96=B4=EC=84=9C=20jwt=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=83=9D=EC=84=B1=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/auth/jwt/JwtFilter.kt | 43 ++++++++++++ .../onku/backend/global/auth/jwt/JwtUtil.kt | 67 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/auth/jwt/JwtFilter.kt create mode 100644 src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt 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..7dd2fbe --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/jwt/JwtFilter.kt @@ -0,0 +1,43 @@ +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 scopes = jwtUtil.getScopes(token) + + val authorities = buildList { + roles.forEach { add(SimpleGrantedAuthority("ROLE_${it.uppercase()}")) } + scopes.forEach { add(SimpleGrantedAuthority(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..f30d733 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt @@ -0,0 +1,67 @@ +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 org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.util.* +import javax.crypto.SecretKey + +@Component +class JwtUtil( + @Value("\${jwt.secret}") secret: String, + @Value("\${jwt.expiration:1800}") private val accessExpireMinutes: Long +) { + 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 getScopes(token: String): List = parseClaims(token).get("scopes", 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 = listOf("USER")): String = + createJwt(email, roles, scopes = emptyList(), expiredMs = accessExpireMinutes * 60 * 1000) + + fun createRefreshToken(email: String, roles: List = listOf("USER")): String { + val refreshMs = 1000L * 60 * 60 * 24 * 7 // 7일 + return createJwt(email, roles, scopes = emptyList(), expiredMs = refreshMs) + } + + fun createOnboardingToken(email: String, minutes: Long = 30): String = + createJwt(email, roles = listOf("GUEST"), scopes = listOf("ONBOARDING_ONLY"), expiredMs = minutes * 60 * 1000) + + 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() + } +} From ac8d15051f6252fe018a261b72bab02f3a4e1b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:14:34 +0900 Subject: [PATCH 025/470] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9A=94=EC=B2=AD=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/service/AuthService.kt | 118 ++++++++++++++++++ .../global/auth/service/KakaoService.kt | 56 +++++++++ 2 files changed, 174 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/auth/service/AuthService.kt create mode 100644 src/main/kotlin/onku/backend/global/auth/service/KakaoService.kt 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..3aa3a0c --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt @@ -0,0 +1,118 @@ +package onku.backend.global.auth.service + +import onku.backend.domain.member.Member +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.service.MemberService +import onku.backend.global.auth.AuthErrorCode +import onku.backend.global.auth.dto.KakaoLoginRequest +import onku.backend.global.auth.jwt.JwtUtil +import onku.backend.global.exception.CustomException +import onku.backend.global.redis.RefreshTokenCacheUtil +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 + +interface AuthService { + fun reissueAccessToken(refreshToken: String): String + fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity +} + +@Service +@Transactional(readOnly = true) +class AuthServiceImpl( + private val memberService: MemberService, + private val kakaoService: KakaoService, + private val jwtUtil: JwtUtil, + private val refreshTokenCacheUtil: RefreshTokenCacheUtil +) : AuthService { + + private fun rolesFor(member: Member): List = + when (member.role) { + Role.ADMIN -> listOf("ADMIN", "USER") + Role.USER -> listOf("USER") + } + + @Transactional + override fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity { + val token = kakaoService.getAccessToken(dto.code) + val profile = kakaoService.getProfile(token.accessToken) + + val socialId: String = profile.id.toString() + val email: String = profile.kakaoAccount?.email + ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) + + val member: Member = memberService.upsertSocialMember( + email = email, + socialId = socialId, + type = SocialType.KAKAO + ) + + return when (member.approval) { + ApprovalStatus.APPROVED -> { + val roles = rolesFor(member) + val access = jwtUtil.createAccessToken(email, roles = roles) + val refresh = jwtUtil.createRefreshToken(email, roles = roles) + refreshTokenCacheUtil.saveRefreshToken(email, refresh, Duration.ofDays(7)) + + ResponseEntity.ok() + .header("Authorization", "Bearer $access") + .header("Refresh-Token", refresh) + .body(mapOf("status" to "APPROVED")) + } + + ApprovalStatus.PENDING -> { + if (member.hasInfo) { + ResponseEntity.status(HttpStatus.ACCEPTED) + .body( + mapOf( + "status" to "PENDING", + "message" to "서비스 승인 대기중입니다. 1주일 이상 승인되지 않을 시 경영총괄팀으로 문의주세요." + ) + ) + } else { + val onboarding = jwtUtil.createOnboardingToken(email, minutes = 30) + ResponseEntity.ok() + .header("Authorization", "Bearer $onboarding") + .body( + mapOf( + "status" to "PENDING", + "allowedEndpoint" to "/api/v1/members/onboarding", + "expiresInMinutes" to 30 + ) + ) + } + } + + ApprovalStatus.REJECTED -> { + ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(mapOf("status" to "REJECTED")) + } + } + } + + override fun reissueAccessToken(refreshToken: String): String { + 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 = rolesFor(member) + return jwtUtil.createAccessToken(email, roles) + } +} 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..51f1b03 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/service/KakaoService.kt @@ -0,0 +1,56 @@ +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.beans.factory.annotation.Value +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( + @Value("\${oauth.kakao.client-id}") private val clientId: String, + @Value("\${oauth.kakao.redirect-uri}") private val redirectUri: String +) { + private val client = RestClient.create() + + fun getAccessToken(code: 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) + } + } +} From cbfd4ac87566086600a03ebec2977bfce9b65879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:15:48 +0900 Subject: [PATCH 026/470] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=99=80=EC=9D=98=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=20=ED=95=84=EC=9A=94=ED=95=9C=20dto=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/auth/dto/KakaoAccount.kt | 11 +++++++++++ .../onku/backend/global/auth/dto/KakaoLoginRequest.kt | 5 +++++ .../global/auth/dto/KakaoOAuthTokenResponse.kt | 10 ++++++++++ .../onku/backend/global/auth/dto/KakaoProfile.kt | 8 ++++++++ 4 files changed, 34 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/auth/dto/KakaoAccount.kt create mode 100644 src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginRequest.kt create mode 100644 src/main/kotlin/onku/backend/global/auth/dto/KakaoOAuthTokenResponse.kt create mode 100644 src/main/kotlin/onku/backend/global/auth/dto/KakaoProfile.kt 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..f4e287f --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginRequest.kt @@ -0,0 +1,5 @@ +package onku.backend.global.auth.dto + +data class KakaoLoginRequest( + val code: String +) \ 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 From 4ecac7fc1dc7555b7a768df1f4c5c38ef19292f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:16:45 +0900 Subject: [PATCH 027/470] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B3=BC=EC=A0=95=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/auth/AuthErrorCode.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt 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..05b43f6 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt @@ -0,0 +1,18 @@ +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), +} From 59af093c8d29411492125643f1a4bf16a3e84511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:17:53 +0900 Subject: [PATCH 028/470] =?UTF-8?q?feat:=20=EC=8B=9C=ED=81=90=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=EB=8B=A8=EC=97=90=EC=84=9C=EC=9D=98=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=EB=B3=84=20=ED=97=88=EC=9A=A9=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=A0=95=EC=9D=98=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/config/SecurityConfig.kt | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt 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..0a309fc --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -0,0 +1,58 @@ +package onku.backend.global.auth.config + +import onku.backend.global.auth.jwt.JwtFilter +import onku.backend.global.auth.jwt.JwtUtil +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.Customizer +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", + ) + private val ALLOWED_POST = arrayOf( + "/api/v1/auth/kakao", + "/api/v1/auth/reissue", + ) + private const val ONBOARDING_ENDPOINT = "/api/v1/members/onboarding" + } + + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .cors(Customizer.withDefaults()) + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { + it + // 공개 엔드포인트 + .requestMatchers(*ALLOWED_GET).permitAll() + .requestMatchers(*ALLOWED_POST).permitAll() + + // 권한 별 엔드포인트 + // 온보딩 + .requestMatchers(ONBOARDING_ENDPOINT).hasAuthority("ONBOARDING_ONLY") + + // 관리자 + .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + + .anyRequest().hasRole("USER") + } + .addFilterBefore(JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter::class.java) + + return http.build() + } +} From bb7f61d1b6516c18bd72eb997968af241618f728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:19:21 +0900 Subject: [PATCH 029/470] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B3=BC=EC=A0=95=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EC=99=80=20=EC=98=A8=EB=B3=B4=EB=94=A9=20=EA=B3=BC=EC=A0=95?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EA=B5=AC=EB=B6=84=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/Member.kt | 42 +++++++++++++++++ .../backend/domain/member/MemberProfile.kt | 47 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/Member.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/MemberProfile.kt 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..7e7fed7 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/Member.kt @@ -0,0 +1,42 @@ +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 + +@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) + val 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 +) { + fun approve() { this.approval = ApprovalStatus.APPROVED } + 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 + } +} 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..3ee53d3 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt @@ -0,0 +1,47 @@ +package onku.backend.domain.member + +import jakarta.persistence.* +import onku.backend.domain.member.enums.Part + +@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 = 100) + 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 = 30) + var phoneNumber: 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 + } +} From 7ef658c863a3bb0869bc6e8eb1c38c6d851219d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:20:28 +0900 Subject: [PATCH 030/470] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9D=84=20=EC=9C=84=ED=95=9C=20dto=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/member/dto/OnboardingRequest.kt | 12 ++++++++++++ .../backend/domain/member/dto/OnboardingResponse.kt | 8 ++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/OnboardingResponse.kt 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..36a7fdd --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.member.dto + +import jakarta.validation.constraints.NotBlank +import onku.backend.domain.member.enums.Part + +data class OnboardingRequest ( + @field:NotBlank val name: String, + val school: String? = null, + val major: String? = null, + val part: Part, + val phoneNumber: 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..7d0324d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingResponse.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.member.dto + +import onku.backend.domain.member.enums.ApprovalStatus + +data class OnboardingResponse( + val status: ApprovalStatus, + val message: String +) \ No newline at end of file From bc3d03dd910e4a910419107fc26d69ed2cb86fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:21:26 +0900 Subject: [PATCH 031/470] =?UTF-8?q?feat:=20=EC=83=81=EC=88=98=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20enum=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt | 3 +++ src/main/kotlin/onku/backend/domain/member/enums/Part.kt | 3 +++ src/main/kotlin/onku/backend/domain/member/enums/Role.kt | 3 +++ src/main/kotlin/onku/backend/domain/member/enums/SocialType.kt | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/enums/Part.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/enums/Role.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/enums/SocialType.kt 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..ebdd70a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt @@ -0,0 +1,3 @@ +package onku.backend.domain.member.enums + +enum class ApprovalStatus { PENDING, APPROVED, 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..9774bf9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/enums/Part.kt @@ -0,0 +1,3 @@ +package onku.backend.domain.member.enums + +enum class Part { BACKEND, FRONTEND, DESIGN, 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..6c12c58 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/enums/Role.kt @@ -0,0 +1,3 @@ +package onku.backend.domain.member.enums + +enum class Role { USER, ADMIN } \ No newline at end of file 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..dfbbc31 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/enums/SocialType.kt @@ -0,0 +1,3 @@ +package onku.backend.domain.member.enums + +enum class SocialType { KAKAO, APPLE } From 0e7873b93ffbd8e9184f853d7a13d5c68ee68f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:22:12 +0900 Subject: [PATCH 032/470] =?UTF-8?q?feat:=20member=20repository=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/repository/MemberProfileRepository.kt | 6 ++++++ .../domain/member/repository/MemberRepository.kt | 10 ++++++++++ 2 files changed, 16 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt 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..751ba26 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt @@ -0,0 +1,6 @@ +package onku.backend.domain.member.repository + +import onku.backend.domain.member.MemberProfile +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberProfileRepository : JpaRepository 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..c4ec91a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.member.repository + +import onku.backend.domain.member.Member +import onku.backend.domain.member.enums.SocialType +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberRepository : JpaRepository { + fun findByEmail(email: String): Member? + fun findBySocialIdAndSocialType(socialId: String, socialType: SocialType): Member? +} \ No newline at end of file From 9acfd971792048957ccd338f4f679146e11cd037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:23:08 +0900 Subject: [PATCH 033/470] =?UTF-8?q?feat:=20member=20service=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/service/MemberProfileService.kt | 46 +++++++++++++++ .../domain/member/service/MemberService.kt | 56 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/service/MemberService.kt 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..0e6cedf --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -0,0 +1,46 @@ +package onku.backend.domain.member.service + +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.enums.Part +import onku.backend.domain.member.dto.OnboardingRequest +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.global.exception.CustomException +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 +) { + 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 + ) + memberProfileRepository.save(profile) + } else { + existing.apply( + name = req.name, + school = req.school, + major = req.major, + part = req.part, + phoneNumber = req.phoneNumber + ) + } + memberService.markOnboarded(member) + } +} 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..de1382a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -0,0 +1,56 @@ +package onku.backend.domain.member.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.Role +import onku.backend.domain.member.enums.SocialType +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.global.exception.CustomException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class MemberService( + private val memberRepository: MemberRepository, +) { + fun getByEmail(email: String): Member = + memberRepository.findByEmail(email) + ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + + fun getBySocialIdOrNull(socialId: String, socialType: SocialType): Member? = + memberRepository.findBySocialIdAndSocialType(socialId, socialType) + + fun getBySocialId(socialId: String, socialType: SocialType): Member = + getBySocialIdOrNull(socialId, socialType) + ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + + @Transactional + fun upsertSocialMember(email: String?, socialId: String, type: SocialType): Member { + val existing = memberRepository.findBySocialIdAndSocialType(socialId, type) + if (existing != null) { + if (!email.isNullOrBlank() && existing.email != email) { + existing.updateEmail(email) + } + return existing + } + + val created = Member( + email = email, + role = Role.USER, + socialType = type, + socialId = socialId, + hasInfo = false, + approval = ApprovalStatus.PENDING + ) + return memberRepository.save(created) + } + + @Transactional + fun markOnboarded(member: Member) { + if (!member.hasInfo) { + member.onboarded() + } + } +} From 3705e4af0d83385220cad71b32e3dcbcb37053ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:23:47 +0900 Subject: [PATCH 034/470] =?UTF-8?q?feat:=20member=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EC=B2=98=EB=A6=AC=EC=9A=A9=20enum=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/MemberErrorCode.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt 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..990dd9d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt @@ -0,0 +1,13 @@ +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), +} From b854c80440ade8be806a115f1a14b04a8a795724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:29:46 +0900 Subject: [PATCH 035/470] =?UTF-8?q?feat:=20approval=20status=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EA=B0=92=20=EC=88=98=EC=A0=95=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/admin/service/AdminService.kt | 9 +++++ .../domain/admin/service/AdminServiceImpl.kt | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt create mode 100644 src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt diff --git a/src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt b/src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt new file mode 100644 index 0000000..96de840 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt @@ -0,0 +1,9 @@ +package onku.backend.domain.admin.service + +import onku.backend.domain.admin.dto.MemberApprovalResponse +import onku.backend.domain.member.enums.ApprovalStatus + + +interface AdminService { + fun updateApproval(memberId: Long, targetStatus: ApprovalStatus): MemberApprovalResponse +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt b/src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt new file mode 100644 index 0000000..eafa75b --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt @@ -0,0 +1,40 @@ +package onku.backend.domain.admin.service + +import onku.backend.domain.admin.dto.MemberApprovalResponse +import onku.backend.domain.member.Member +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.member.MemberErrorCode +import onku.backend.global.exception.CustomException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional +class AdminServiceImpl( + private val memberRepository: MemberRepository +) : AdminService { + + override fun updateApproval(memberId: Long, targetStatus: ApprovalStatus): MemberApprovalResponse { + val member: Member = memberRepository.findById(memberId) + .orElseThrow { 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 -> throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) + } + + val saved = memberRepository.save(member) + + return MemberApprovalResponse( + memberId = saved.id!!, + role = saved.role, + approval = saved.approval + ) + } +} From a5a7bd602b60af0ca146c614dde66be04432e3eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:30:40 +0900 Subject: [PATCH 036/470] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20A?= =?UTF-8?q?PI=20=EC=B6=94=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt diff --git a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt new file mode 100644 index 0000000..91a337f --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt @@ -0,0 +1,46 @@ +package onku.backend.domain.member.controller + +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.OnboardingRequest +import onku.backend.domain.member.dto.OnboardingResponse +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.service.MemberProfileService +import onku.backend.domain.member.service.MemberService +import onku.backend.global.response.SuccessResponse +import org.springframework.http.HttpStatus +import org.springframework.security.core.Authentication +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/members") +@Tag(name = "회원 API", description = "온보딩 관련 API") +class MemberController( + private val memberService: MemberService, + private val memberProfileService: MemberProfileService +) { + + @PostMapping("/onboarding") + @ResponseStatus(HttpStatus.ACCEPTED) + @Operation( + summary = "온보딩 정보 제출", + description = "소셜 로그인 시도 후 회원가입이 되지 않았고 온보딩을 완료하지 않은 회원에게 발급되는 온보딩 전용 토큰으로 접근 가능." + ) + fun submitOnboarding( + auth: Authentication, + @RequestBody @Valid req: OnboardingRequest + ): SuccessResponse { + val email = auth.name + val member = memberService.getByEmail(email) + + memberProfileService.createOrUpdateProfile(member.id!!, req) + memberService.markOnboarded(member) + + val body = OnboardingResponse( + status = ApprovalStatus.PENDING, + message = "온보딩이 접수되었습니다. 운영진 승인 후 로그인할 수 있습니다." + ) + return SuccessResponse.ok(body) + } +} \ No newline at end of file From a5ec5aa411862e872239042736c3527ad106982f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:31:07 +0900 Subject: [PATCH 037/470] =?UTF-8?q?feat:=20admin=20dto=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/admin/dto/MemberApprovalResponse.kt | 10 ++++++++++ .../backend/domain/admin/dto/UpdateApprovalRequest.kt | 9 +++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/admin/dto/MemberApprovalResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/admin/dto/UpdateApprovalRequest.kt diff --git a/src/main/kotlin/onku/backend/domain/admin/dto/MemberApprovalResponse.kt b/src/main/kotlin/onku/backend/domain/admin/dto/MemberApprovalResponse.kt new file mode 100644 index 0000000..71a8313 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/admin/dto/MemberApprovalResponse.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.admin.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/admin/dto/UpdateApprovalRequest.kt b/src/main/kotlin/onku/backend/domain/admin/dto/UpdateApprovalRequest.kt new file mode 100644 index 0000000..767bbaf --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/admin/dto/UpdateApprovalRequest.kt @@ -0,0 +1,9 @@ +package onku.backend.domain.admin.dto + +import jakarta.validation.constraints.NotNull +import onku.backend.domain.member.enums.ApprovalStatus + +data class UpdateApprovalRequest( + @field:NotNull + val status: ApprovalStatus +) \ No newline at end of file From 396c28d90aa2370bb0ea952491ed57a10eea28d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:31:59 +0900 Subject: [PATCH 038/470] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8A=B9=EC=9D=B8=20API=20=EC=B6=94=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminController.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt diff --git a/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt b/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt new file mode 100644 index 0000000..3aa3e80 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt @@ -0,0 +1,47 @@ +package onku.backend.domain.admin.controller + +import onku.backend.domain.admin.dto.UpdateApprovalRequest +import onku.backend.domain.admin.service.AdminService +import onku.backend.domain.admin.dto.MemberApprovalResponse +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.global.response.ErrorResponse +import onku.backend.global.response.SuccessResponse + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.* + +@Tag(name = "관리자 API") +@RestController +@RequestMapping("/api/v1/admin/members") +class AdminController( + private val adminService: AdminService +) { + + @Operation( + summary = "[관리자] 회원 승인 상태 변경 (PENDING → APPROVED/REJECTED)", + description = "PENDING 상태의 회원만 승인/거절할 수 있습니다." + ) + @PatchMapping("/{memberId}/approval") + @PreAuthorize("hasRole('ADMIN')") + fun updateApproval( + @PathVariable memberId: Long, + @RequestBody @Valid body: UpdateApprovalRequest + ): ResponseEntity { + + if (body.status == ApprovalStatus.PENDING) { + val error = ErrorResponse.of( + code = "INVALID_REQUEST", + message = "PENDING 으로 변경할 수 없습니다." + ) + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error) + } + + val result: MemberApprovalResponse = adminService.updateApproval(memberId, body.status) + return ResponseEntity.ok(SuccessResponse.ok(result)) + } +} From e83c483a6739104b7a77f4ca65139e4accdf0618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 18:33:10 +0900 Subject: [PATCH 039/470] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20A?= =?UTF-8?q?PI=20=EC=B6=94=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt 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..7d22eb0 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt @@ -0,0 +1,36 @@ +package onku.backend.global.auth.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +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 @Valid dto: KakaoLoginRequest): ResponseEntity> { + val res = authService.kakaoLogin(dto) + return ResponseEntity + .status(res.statusCode) + .headers(res.headers) + .body(SuccessResponse.ok(res.body)) + } + + @PostMapping("/reissue") + @Operation(summary = "AT 재발급", description = "RT를 헤더로 받아 AT를 재발급합니다.") + fun reissue(@RequestHeader("Refresh-Token") refreshToken: String): ResponseEntity> { + val newAccess = authService.reissueAccessToken(refreshToken) + return ResponseEntity.ok() + .header("Authorization", "Bearer $newAccess") + .body(SuccessResponse.ok("Access Token이 재발급되었습니다.")) + } +} From 00c6006bf3eebc236c1f02e8ef5ae7fff7c77eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 19:09:34 +0900 Subject: [PATCH 040/470] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=9B=84=20role=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/auth/dto/KakaoLoginResult.kt | 14 ++++++++++++++ .../backend/global/auth/service/AuthService.kt | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginResult.kt 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/service/AuthService.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt index 3aa3a0c..64871ac 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt @@ -61,7 +61,13 @@ class AuthServiceImpl( ResponseEntity.ok() .header("Authorization", "Bearer $access") .header("Refresh-Token", refresh) - .body(mapOf("status" to "APPROVED")) + .body( + mapOf( + "status" to "APPROVED", + "memberId" to member.id, + "role" to member.role.name + ) + ) } ApprovalStatus.PENDING -> { From db1ddc9bdf09dc75a61e5f91f5631af1d5943920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 19:20:57 +0900 Subject: [PATCH 041/470] =?UTF-8?q?feat:=20jwt=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20Member=20=EA=B0=9D=EC=B2=B4=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=EC=9A=A9=20@CurrentMember=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/annotation/CurrentMember.kt | 8 +++++ .../resolver/AuthenticatedMemberResolver.kt | 35 +++++++++++++++++++ .../onku/backend/global/config/WebConfig.kt | 15 ++++++++ 3 files changed, 58 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/annotation/CurrentMember.kt create mode 100644 src/main/kotlin/onku/backend/global/annotation/resolver/AuthenticatedMemberResolver.kt create mode 100644 src/main/kotlin/onku/backend/global/config/WebConfig.kt 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/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) + } +} From 7816fdae07da2724e5ac56d9d70eebbff92ff59e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 19:21:25 +0900 Subject: [PATCH 042/470] =?UTF-8?q?chore:=20=EC=83=88=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20API=20=EC=B6=94=EA=B0=80=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/test/TestController.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/test/TestController.kt b/src/main/kotlin/onku/backend/domain/test/TestController.kt index ae18072..ba6b6cb 100644 --- a/src/main/kotlin/onku/backend/domain/test/TestController.kt +++ b/src/main/kotlin/onku/backend/domain/test/TestController.kt @@ -5,7 +5,9 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import jakarta.validation.constraints.Min +import onku.backend.domain.member.Member import onku.backend.domain.test.dto.TestDto +import onku.backend.global.annotation.CurrentMember import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode import onku.backend.global.response.SuccessResponse @@ -55,4 +57,8 @@ class TestController { fun untracked(): String { throw IllegalStateException("테스트용 미등록 예외") } + + @GetMapping("/annotation") + fun annotation(@CurrentMember member: Member): SuccessResponse = + SuccessResponse.ok("MEMBER_ID=$member.id") } \ No newline at end of file From b62f7729f679f6fffaf7036da4f7d9be1e27c43b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 20:24:10 +0900 Subject: [PATCH 043/470] =?UTF-8?q?feat:=20auth=20service=EB=A1=9C=20contr?= =?UTF-8?q?oller=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8A=94?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=A7=8C=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/auth/dto/AuthHeaders.kt | 4 + .../global/auth/dto/AuthLoginResult.kt | 4 + .../global/auth/service/AuthService.kt | 122 +----------------- .../global/auth/service/AuthServiceImpl.kt | 105 +++++++++++++++ 4 files changed, 117 insertions(+), 118 deletions(-) create mode 100644 src/main/kotlin/onku/backend/global/auth/dto/AuthHeaders.kt create mode 100644 src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt create mode 100644 src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt 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..08f5656 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/AuthHeaders.kt @@ -0,0 +1,4 @@ +package onku.backend.global.auth.dto + +class AuthHeaders { +} \ 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..61a3143 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt @@ -0,0 +1,4 @@ +package onku.backend.global.auth.dto + +class AuthLoginResult { +} \ 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 index 64871ac..d8dfa4a 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt @@ -1,124 +1,10 @@ package onku.backend.global.auth.service -import onku.backend.domain.member.Member -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.service.MemberService -import onku.backend.global.auth.AuthErrorCode +import onku.backend.global.auth.dto.AuthHeaders +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.exception.CustomException -import onku.backend.global.redis.RefreshTokenCacheUtil -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 interface AuthService { fun reissueAccessToken(refreshToken: String): String - fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity -} - -@Service -@Transactional(readOnly = true) -class AuthServiceImpl( - private val memberService: MemberService, - private val kakaoService: KakaoService, - private val jwtUtil: JwtUtil, - private val refreshTokenCacheUtil: RefreshTokenCacheUtil -) : AuthService { - - private fun rolesFor(member: Member): List = - when (member.role) { - Role.ADMIN -> listOf("ADMIN", "USER") - Role.USER -> listOf("USER") - } - - @Transactional - override fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity { - val token = kakaoService.getAccessToken(dto.code) - val profile = kakaoService.getProfile(token.accessToken) - - val socialId: String = profile.id.toString() - val email: String = profile.kakaoAccount?.email - ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) - - val member: Member = memberService.upsertSocialMember( - email = email, - socialId = socialId, - type = SocialType.KAKAO - ) - - return when (member.approval) { - ApprovalStatus.APPROVED -> { - val roles = rolesFor(member) - val access = jwtUtil.createAccessToken(email, roles = roles) - val refresh = jwtUtil.createRefreshToken(email, roles = roles) - refreshTokenCacheUtil.saveRefreshToken(email, refresh, Duration.ofDays(7)) - - ResponseEntity.ok() - .header("Authorization", "Bearer $access") - .header("Refresh-Token", refresh) - .body( - mapOf( - "status" to "APPROVED", - "memberId" to member.id, - "role" to member.role.name - ) - ) - } - - ApprovalStatus.PENDING -> { - if (member.hasInfo) { - ResponseEntity.status(HttpStatus.ACCEPTED) - .body( - mapOf( - "status" to "PENDING", - "message" to "서비스 승인 대기중입니다. 1주일 이상 승인되지 않을 시 경영총괄팀으로 문의주세요." - ) - ) - } else { - val onboarding = jwtUtil.createOnboardingToken(email, minutes = 30) - ResponseEntity.ok() - .header("Authorization", "Bearer $onboarding") - .body( - mapOf( - "status" to "PENDING", - "allowedEndpoint" to "/api/v1/members/onboarding", - "expiresInMinutes" to 30 - ) - ) - } - } - - ApprovalStatus.REJECTED -> { - ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(mapOf("status" to "REJECTED")) - } - } - } - - override fun reissueAccessToken(refreshToken: String): String { - 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 = rolesFor(member) - return jwtUtil.createAccessToken(email, roles) - } -} + fun kakaoLogin(dto: KakaoLoginRequest): Pair +} \ No newline at end of file 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..3e0b3b1 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -0,0 +1,105 @@ +package onku.backend.global.auth.service + +import onku.backend.domain.member.Member +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.service.MemberService +import onku.backend.global.auth.AuthErrorCode +import onku.backend.global.auth.dto.* +import onku.backend.global.auth.jwt.JwtUtil +import onku.backend.global.exception.CustomException +import onku.backend.global.redis.RefreshTokenCacheUtil +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 jwtUtil: JwtUtil, + private val refreshTokenCacheUtil: RefreshTokenCacheUtil +) : AuthService { + + private fun rolesFor(member: Member): List = + when (member.role) { + Role.ADMIN -> listOf("ADMIN", "USER") + Role.USER -> listOf("USER") + } + + @Transactional + override fun kakaoLogin(dto: KakaoLoginRequest): Pair { + val token = kakaoService.getAccessToken(dto.code) + val profile = kakaoService.getProfile(token.accessToken) + + val socialId: String = profile.id.toString() + val email: String = profile.kakaoAccount?.email + ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) + + val member: Member = memberService.upsertSocialMember( + email = email, + socialId = socialId, + type = SocialType.KAKAO + ) + + return when (member.approval) { + ApprovalStatus.APPROVED -> { + val roles = rolesFor(member) + val access = jwtUtil.createAccessToken(email, roles = roles) + val refresh = jwtUtil.createRefreshToken(email, roles = roles) + refreshTokenCacheUtil.saveRefreshToken(email, refresh, Duration.ofDays(7)) + + AuthLoginResult( + status = ApprovalStatus.APPROVED, + memberId = member.id, + role = member.role.name + ) to AuthHeaders( + accessToken = access, + refreshToken = refresh + ) + } + + ApprovalStatus.PENDING -> { + if (member.hasInfo) { + AuthLoginResult( + status = ApprovalStatus.PENDING, + ) to AuthHeaders() + } else { + val onboarding = jwtUtil.createOnboardingToken(email, minutes = 30) + AuthLoginResult( + status = ApprovalStatus.PENDING, + ) to AuthHeaders( + onboardingToken = onboarding + ) + } + } + + ApprovalStatus.REJECTED -> { + AuthLoginResult( + status = ApprovalStatus.REJECTED + ) to AuthHeaders() + } + } + } + + override fun reissueAccessToken(refreshToken: String): String { + 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 = rolesFor(member) + return jwtUtil.createAccessToken(email, roles) + } +} From 774355b069525a7f277779cc116c8b40b498c497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 20:25:59 +0900 Subject: [PATCH 044/470] =?UTF-8?q?feat:=20HTTP=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EA=B0=92=EC=9D=84=20controller=EC=97=90=EC=84=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.kt | 35 ++++++++++++++++--- .../backend/global/auth/dto/AuthHeaders.kt | 7 ++-- .../global/auth/dto/AuthLoginResult.kt | 9 +++-- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt index 7d22eb0..f072da3 100644 --- a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt +++ b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt @@ -3,9 +3,11 @@ package onku.backend.global.auth.controller import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid +import onku.backend.domain.member.enums.ApprovalStatus import onku.backend.global.auth.dto.KakaoLoginRequest import onku.backend.global.auth.service.AuthService import onku.backend.global.response.SuccessResponse +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -18,11 +20,34 @@ class AuthController( @PostMapping("/kakao") @Operation(summary = "카카오 로그인", description = "인가코드를 body로 받아 사용자를 식별합니다.") fun kakaoLogin(@RequestBody @Valid dto: KakaoLoginRequest): ResponseEntity> { - val res = authService.kakaoLogin(dto) - return ResponseEntity - .status(res.statusCode) - .headers(res.headers) - .body(SuccessResponse.ok(res.body)) + val (result, headers) = authService.kakaoLogin(dto) + + return when (result.status) { + ApprovalStatus.APPROVED -> { + ResponseEntity.ok() + .apply { + headers.accessToken?.let { header("Authorization", "Bearer $it") } + headers.refreshToken?.let { header("Refresh-Token", it) } + } + .body(SuccessResponse.ok(result)) + } + + ApprovalStatus.PENDING -> { + if (headers.onboardingToken != null) { + ResponseEntity.ok() + .header("Authorization", "Bearer ${headers.onboardingToken}") + .body(SuccessResponse.ok(result)) + } else { + ResponseEntity.status(HttpStatus.ACCEPTED) + .body(SuccessResponse.ok(result)) + } + } + + ApprovalStatus.REJECTED -> { + ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(SuccessResponse.ok(result)) + } + } } @PostMapping("/reissue") diff --git a/src/main/kotlin/onku/backend/global/auth/dto/AuthHeaders.kt b/src/main/kotlin/onku/backend/global/auth/dto/AuthHeaders.kt index 08f5656..7a52cf3 100644 --- a/src/main/kotlin/onku/backend/global/auth/dto/AuthHeaders.kt +++ b/src/main/kotlin/onku/backend/global/auth/dto/AuthHeaders.kt @@ -1,4 +1,7 @@ package onku.backend.global.auth.dto -class AuthHeaders { -} \ No newline at end of file +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 index 61a3143..e9c7d07 100644 --- a/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt +++ b/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt @@ -1,4 +1,9 @@ package onku.backend.global.auth.dto -class AuthLoginResult { -} \ No newline at end of file +import onku.backend.domain.member.enums.ApprovalStatus + +data class AuthLoginResult( + val status: ApprovalStatus, + val memberId: Long? = null, + val role: String? = null, +) \ No newline at end of file From 452a03f1ab089903b116257ae50a836aa9a80baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 20:26:31 +0900 Subject: [PATCH 045/470] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/global/auth/config/SecurityConfig.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index 0a309fc..d131e97 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -43,12 +43,8 @@ class SecurityConfig( .requestMatchers(*ALLOWED_POST).permitAll() // 권한 별 엔드포인트 - // 온보딩 .requestMatchers(ONBOARDING_ENDPOINT).hasAuthority("ONBOARDING_ONLY") - - // 관리자 .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") - .anyRequest().hasRole("USER") } .addFilterBefore(JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter::class.java) From c401dbfba51e7eda8d48dc68b093fa77e5b451a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 20:27:15 +0900 Subject: [PATCH 046/470] =?UTF-8?q?chore:=20swagger=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/admin/controller/AdminController.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt b/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt index 3aa3e80..386964e 100644 --- a/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt +++ b/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt @@ -15,7 +15,7 @@ import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* -@Tag(name = "관리자 API") +@Tag(name = "관리자 API", description = "관리자 권한용 API") @RestController @RequestMapping("/api/v1/admin/members") class AdminController( @@ -23,8 +23,8 @@ class AdminController( ) { @Operation( - summary = "[관리자] 회원 승인 상태 변경 (PENDING → APPROVED/REJECTED)", - description = "PENDING 상태의 회원만 승인/거절할 수 있습니다." + summary = "[관리자] 회원 승인 상태 변경", + description = "PENDING 상태의 회원만 승인/거절할 수 있습니다. (PENDING → APPROVED/REJECTED)" ) @PatchMapping("/{memberId}/approval") @PreAuthorize("hasRole('ADMIN')") From 7393f8f76339688b1a1b5fd7403ea6513d5e0ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 5 Oct 2025 20:27:50 +0900 Subject: [PATCH 047/470] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=20=EC=A0=9C=EA=B1=B0=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/admin/service/AdminService.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt b/src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt index 96de840..35c24a4 100644 --- a/src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt +++ b/src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt @@ -3,7 +3,6 @@ package onku.backend.domain.admin.service import onku.backend.domain.admin.dto.MemberApprovalResponse import onku.backend.domain.member.enums.ApprovalStatus - interface AdminService { fun updateApproval(memberId: Long, targetStatus: ApprovalStatus): MemberApprovalResponse } \ No newline at end of file From 89246c2442372b161979b1bd97d083230c6caa92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 7 Oct 2025 16:10:14 +0900 Subject: [PATCH 048/470] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=97=90=EC=84=9C=20school,=20major=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=8F=84=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/dto/OnboardingRequest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt index 36a7fdd..ebc6441 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt @@ -5,8 +5,8 @@ import onku.backend.domain.member.enums.Part data class OnboardingRequest ( @field:NotBlank val name: String, - val school: String? = null, - val major: String? = null, + @field:NotBlank val school: String, + @field:NotBlank val major: String, val part: Part, val phoneNumber: String? = null ) From e69818f2b6af33411c7cef379880472e9ac2b0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 7 Oct 2025 17:46:17 +0900 Subject: [PATCH 049/470] =?UTF-8?q?refactor:=20AdminController=EC=9D=98=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EC=83=81=ED=83=9C=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20service=EB=8B=A8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9C=84=EC=9E=84=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminController.kt | 19 +++---------------- .../domain/admin/service/AdminServiceImpl.kt | 6 +++++- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt b/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt index 386964e..a62cd8a 100644 --- a/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt +++ b/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt @@ -1,16 +1,12 @@ package onku.backend.domain.admin.controller import onku.backend.domain.admin.dto.UpdateApprovalRequest -import onku.backend.domain.admin.service.AdminService import onku.backend.domain.admin.dto.MemberApprovalResponse -import onku.backend.domain.member.enums.ApprovalStatus -import onku.backend.global.response.ErrorResponse +import onku.backend.domain.admin.service.AdminService import onku.backend.global.response.SuccessResponse - import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid -import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* @@ -31,17 +27,8 @@ class AdminController( fun updateApproval( @PathVariable memberId: Long, @RequestBody @Valid body: UpdateApprovalRequest - ): ResponseEntity { - - if (body.status == ApprovalStatus.PENDING) { - val error = ErrorResponse.of( - code = "INVALID_REQUEST", - message = "PENDING 으로 변경할 수 없습니다." - ) - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error) - } - - val result: MemberApprovalResponse = adminService.updateApproval(memberId, body.status) + ): ResponseEntity> { + val result = adminService.updateApproval(memberId, body.status) return ResponseEntity.ok(SuccessResponse.ok(result)) } } diff --git a/src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt b/src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt index eafa75b..45da661 100644 --- a/src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt +++ b/src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt @@ -16,6 +16,10 @@ class AdminServiceImpl( ) : AdminService { override fun updateApproval(memberId: Long, targetStatus: ApprovalStatus): MemberApprovalResponse { + if (targetStatus == ApprovalStatus.PENDING) { + throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) + } + val member: Member = memberRepository.findById(memberId) .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } @@ -26,7 +30,7 @@ class AdminServiceImpl( when (targetStatus) { ApprovalStatus.APPROVED -> member.approve() ApprovalStatus.REJECTED -> member.reject() - ApprovalStatus.PENDING -> throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) + ApprovalStatus.PENDING -> { } } val saved = memberRepository.save(member) From 987d9249653009e17de6a5daa8e8c7307eecdc60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 7 Oct 2025 17:47:04 +0900 Subject: [PATCH 050/470] =?UTF-8?q?refactor:=20MemberController=EC=9D=98?= =?UTF-8?q?=20=EC=98=A8=EB=B3=B4=EB=94=A9=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20MemberProfileService=EB=A1=9C=20=EC=9C=84?= =?UTF-8?q?=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.kt | 20 ++++------------ .../member/service/MemberProfileService.kt | 24 ++++++++++++++++--- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt index 91a337f..f2eb791 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt @@ -3,21 +3,19 @@ package onku.backend.domain.member.controller 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.OnboardingRequest import onku.backend.domain.member.dto.OnboardingResponse -import onku.backend.domain.member.enums.ApprovalStatus 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 org.springframework.http.HttpStatus -import org.springframework.security.core.Authentication import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/members") @Tag(name = "회원 API", description = "온보딩 관련 API") class MemberController( - private val memberService: MemberService, private val memberProfileService: MemberProfileService ) { @@ -28,19 +26,11 @@ class MemberController( description = "소셜 로그인 시도 후 회원가입이 되지 않았고 온보딩을 완료하지 않은 회원에게 발급되는 온보딩 전용 토큰으로 접근 가능." ) fun submitOnboarding( - auth: Authentication, + @CurrentMember member: Member, @RequestBody @Valid req: OnboardingRequest ): SuccessResponse { - val email = auth.name - val member = memberService.getByEmail(email) - - memberProfileService.createOrUpdateProfile(member.id!!, req) - memberService.markOnboarded(member) - - val body = OnboardingResponse( - status = ApprovalStatus.PENDING, - message = "온보딩이 접수되었습니다. 운영진 승인 후 로그인할 수 있습니다." - ) + val body = memberProfileService.submitOnboarding(member, req) return SuccessResponse.ok(body) } + } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index 0e6cedf..e79fe36 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -1,9 +1,11 @@ package onku.backend.domain.member.service +import onku.backend.domain.member.Member import onku.backend.domain.member.MemberProfile import onku.backend.domain.member.MemberErrorCode -import onku.backend.domain.member.enums.Part import onku.backend.domain.member.dto.OnboardingRequest +import onku.backend.domain.member.dto.OnboardingResponse +import onku.backend.domain.member.enums.ApprovalStatus import onku.backend.domain.member.repository.MemberProfileRepository import onku.backend.domain.member.repository.MemberRepository import onku.backend.global.exception.CustomException @@ -17,7 +19,24 @@ class MemberProfileService( private val memberRepository: MemberRepository, private val memberService: MemberService ) { - fun createOrUpdateProfile(memberId: Long, req: OnboardingRequest) { + 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) + } + + createOrUpdateProfile(member.id!!, req) + memberService.markOnboarded(member) + + return OnboardingResponse( + status = ApprovalStatus.PENDING + ) + } + + private fun createOrUpdateProfile(memberId: Long, req: OnboardingRequest) { val member = memberRepository.findById(memberId) .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } @@ -41,6 +60,5 @@ class MemberProfileService( phoneNumber = req.phoneNumber ) } - memberService.markOnboarded(member) } } From 8ff22523502963af89267c6547a7b7c084d6b24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 7 Oct 2025 17:48:12 +0900 Subject: [PATCH 051/470] =?UTF-8?q?refactor:=20AuthController=EC=9D=98=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=BD=94=EB=93=9C,=20header=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=EB=A1=9C=EC=A7=81=EC=9D=84=20service=EB=8B=A8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9C=84=EC=9E=84=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.kt | 46 ++------- .../global/auth/service/AuthService.kt | 10 +- .../global/auth/service/AuthServiceImpl.kt | 96 ++++++++++++------- 3 files changed, 75 insertions(+), 77 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt index f072da3..20779c3 100644 --- a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt +++ b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt @@ -2,12 +2,10 @@ package onku.backend.global.auth.controller import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag -import jakarta.validation.Valid -import onku.backend.domain.member.enums.ApprovalStatus +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.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -19,43 +17,11 @@ class AuthController( ) { @PostMapping("/kakao") @Operation(summary = "카카오 로그인", description = "인가코드를 body로 받아 사용자를 식별합니다.") - fun kakaoLogin(@RequestBody @Valid dto: KakaoLoginRequest): ResponseEntity> { - val (result, headers) = authService.kakaoLogin(dto) - - return when (result.status) { - ApprovalStatus.APPROVED -> { - ResponseEntity.ok() - .apply { - headers.accessToken?.let { header("Authorization", "Bearer $it") } - headers.refreshToken?.let { header("Refresh-Token", it) } - } - .body(SuccessResponse.ok(result)) - } - - ApprovalStatus.PENDING -> { - if (headers.onboardingToken != null) { - ResponseEntity.ok() - .header("Authorization", "Bearer ${headers.onboardingToken}") - .body(SuccessResponse.ok(result)) - } else { - ResponseEntity.status(HttpStatus.ACCEPTED) - .body(SuccessResponse.ok(result)) - } - } - - ApprovalStatus.REJECTED -> { - ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(SuccessResponse.ok(result)) - } - } - } + fun kakaoLogin(@RequestBody req: KakaoLoginRequest): ResponseEntity> = + authService.kakaoLogin(req) @PostMapping("/reissue") @Operation(summary = "AT 재발급", description = "RT를 헤더로 받아 AT를 재발급합니다.") - fun reissue(@RequestHeader("Refresh-Token") refreshToken: String): ResponseEntity> { - val newAccess = authService.reissueAccessToken(refreshToken) - return ResponseEntity.ok() - .header("Authorization", "Bearer $newAccess") - .body(SuccessResponse.ok("Access Token이 재발급되었습니다.")) - } -} + fun reissue(@RequestHeader("X-Refresh-Token") refreshToken: String): ResponseEntity> = + authService.reissueAccessToken(refreshToken) +} \ 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 index d8dfa4a..ced79cb 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt @@ -1,10 +1,12 @@ package onku.backend.global.auth.service -import onku.backend.global.auth.dto.AuthHeaders 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 reissueAccessToken(refreshToken: String): String - fun kakaoLogin(dto: KakaoLoginRequest): Pair -} \ No newline at end of file + fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity> + fun reissueAccessToken(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 index 3e0b3b1..bef5f01 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -4,12 +4,18 @@ import onku.backend.domain.member.Member 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.service.MemberService import onku.backend.global.auth.AuthErrorCode import onku.backend.global.auth.dto.* import onku.backend.global.auth.jwt.JwtUtil import onku.backend.global.exception.CustomException import onku.backend.global.redis.RefreshTokenCacheUtil +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 @@ -19,8 +25,11 @@ import java.time.Duration class AuthServiceImpl( private val memberService: MemberService, private val kakaoService: KakaoService, + private val memberProfileRepository: MemberProfileRepository, private val jwtUtil: JwtUtil, - private val refreshTokenCacheUtil: RefreshTokenCacheUtil + private val refreshTokenCacheUtil: RefreshTokenCacheUtil, + @Value("\${jwt.refresh-ttl}") private val refreshTtl: Duration, + @Value("\${jwt.onboarding-ttl}") private val onboardingTtl: Duration, ) : AuthService { private fun rolesFor(member: Member): List = @@ -30,15 +39,15 @@ class AuthServiceImpl( } @Transactional - override fun kakaoLogin(dto: KakaoLoginRequest): Pair { + override fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity> { val token = kakaoService.getAccessToken(dto.code) val profile = kakaoService.getProfile(token.accessToken) - val socialId: String = profile.id.toString() - val email: String = profile.kakaoAccount?.email + val socialId = profile.id.toString() + val email = profile.kakaoAccount?.email ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) - val member: Member = memberService.upsertSocialMember( + val member = memberService.upsertSocialMember( email = email, socialId = socialId, type = SocialType.KAKAO @@ -47,51 +56,63 @@ class AuthServiceImpl( return when (member.approval) { ApprovalStatus.APPROVED -> { val roles = rolesFor(member) - val access = jwtUtil.createAccessToken(email, roles = roles) - val refresh = jwtUtil.createRefreshToken(email, roles = roles) - refreshTokenCacheUtil.saveRefreshToken(email, refresh, Duration.ofDays(7)) - - AuthLoginResult( - status = ApprovalStatus.APPROVED, - memberId = member.id, - role = member.role.name - ) to AuthHeaders( - accessToken = access, - refreshToken = refresh - ) + 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.name + ) + ) + ) } ApprovalStatus.PENDING -> { - if (member.hasInfo) { - AuthLoginResult( - status = ApprovalStatus.PENDING, - ) to AuthHeaders() + if (member.hasInfo) { // 이미 프로필이 있으면 온보딩 토큰 미발급 + ResponseEntity + .status(HttpStatus.ACCEPTED) + .body(SuccessResponse.ok(AuthLoginResult(status = ApprovalStatus.PENDING))) } else { - val onboarding = jwtUtil.createOnboardingToken(email, minutes = 30) - AuthLoginResult( - status = ApprovalStatus.PENDING, - ) to AuthHeaders( - onboardingToken = onboarding - ) + 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))) } } ApprovalStatus.REJECTED -> { - AuthLoginResult( - status = ApprovalStatus.REJECTED - ) to AuthHeaders() + ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(SuccessResponse.ok(AuthLoginResult(status = ApprovalStatus.REJECTED))) } } } - override fun reissueAccessToken(refreshToken: String): String { + @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) @@ -100,6 +121,15 @@ class AuthServiceImpl( } val roles = rolesFor(member) - return jwtUtil.createAccessToken(email, roles) + 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이 재발급되었습니다.")) } } From b2791e20b395c0e19f6767850736cb7adc517ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 7 Oct 2025 17:50:28 +0900 Subject: [PATCH 052/470] =?UTF-8?q?fix:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=8B=9C=20hasInfo=EA=B0=80=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=98=81=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/service/MemberService.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt index de1382a..1120c50 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -49,8 +49,12 @@ class MemberService( @Transactional fun markOnboarded(member: Member) { - if (!member.hasInfo) { - member.onboarded() + val m = memberRepository.findById(member.id!!) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + if (!m.hasInfo) { + m.onboarded() + memberRepository.save(m) } } } From d96be517c0ccdd041f4c39a8d84cce2fed8f088a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 7 Oct 2025 17:51:08 +0900 Subject: [PATCH 053/470] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9D=91=EB=8B=B5=ED=95=84=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/member/dto/OnboardingResponse.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/OnboardingResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingResponse.kt index 7d0324d..27decad 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/OnboardingResponse.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingResponse.kt @@ -4,5 +4,4 @@ import onku.backend.domain.member.enums.ApprovalStatus data class OnboardingResponse( val status: ApprovalStatus, - val message: String ) \ No newline at end of file From a97a6a5bc6a6e3f6abf422f1171fab16546a4b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 7 Oct 2025 17:51:42 +0900 Subject: [PATCH 054/470] =?UTF-8?q?refactor:=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=8B=9C=EA=B0=84=20yml=EB=A1=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/auth/jwt/JwtUtil.kt | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt b/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt index f30d733..d772d62 100644 --- a/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt +++ b/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt @@ -7,13 +7,15 @@ import io.jsonwebtoken.io.Decoders import io.jsonwebtoken.security.Keys 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.expiration:1800}") private val accessExpireMinutes: Long + @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)) @@ -31,34 +33,23 @@ class JwtUtil( fun getScopes(token: String): List = parseClaims(token).get("scopes", List::class.java)?.map { it.toString() } ?: emptyList() fun isExpired(token: String): Boolean = - try { - parseClaims(token).expiration?.before(Date()) ?: true - } catch (_: ExpiredJwtException) { - true - } + try { parseClaims(token).expiration?.before(Date()) ?: true } + catch (_: ExpiredJwtException) { true } fun createAccessToken(email: String, roles: List = listOf("USER")): String = - createJwt(email, roles, scopes = emptyList(), expiredMs = accessExpireMinutes * 60 * 1000) + createJwt(email, roles, scopes = emptyList(), expiredMs = accessTtl.toMillis()) - fun createRefreshToken(email: String, roles: List = listOf("USER")): String { - val refreshMs = 1000L * 60 * 60 * 24 * 7 // 7일 - return createJwt(email, roles, scopes = emptyList(), expiredMs = refreshMs) - } + fun createRefreshToken(email: String, roles: List = listOf("USER")): String = + createJwt(email, roles, scopes = emptyList(), expiredMs = refreshTtl.toMillis()) fun createOnboardingToken(email: String, minutes: Long = 30): String = - createJwt(email, roles = listOf("GUEST"), scopes = listOf("ONBOARDING_ONLY"), expiredMs = minutes * 60 * 1000) + createJwt(email, roles = listOf("GUEST"), scopes = listOf("ONBOARDING_ONLY"), 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 - ) - ) + .claims(mapOf("email" to email, "roles" to roles, "scopes" to scopes)) .issuedAt(now) .expiration(exp) .signWith(key) From 1d9b322f5a5a337ddd8ae0cb902a7bad6cc00995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 7 Oct 2025 21:22:15 +0900 Subject: [PATCH 055/470] =?UTF-8?q?feat=20:=20cors=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/config/SecurityConfig.kt | 8 +++-- .../backend/global/config/CustomCorsConfig.kt | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index d131e97..06fa940 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -2,9 +2,9 @@ package onku.backend.global.auth.config 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.Customizer 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 @@ -31,10 +31,12 @@ class SecurityConfig( } @Bean - fun filterChain(http: HttpSecurity): SecurityFilterChain { + fun filterChain(http: HttpSecurity, corsConfiguration: CustomCorsConfig): SecurityFilterChain { http .csrf { it.disable() } - .cors(Customizer.withDefaults()) + .cors{it.configurationSource( + corsConfiguration.corsConfigurationSource() + )} .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .authorizeHttpRequests { it 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..c856c1c --- /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", "DELETE", "OPTIONS") + configuration.allowedHeaders = listOf("*") + configuration.allowCredentials = true + configuration.exposedHeaders = listOf("Authorization", "Set-Cookie") + configuration.maxAge = 3600 + val source = UrlBasedCorsConfigurationSource() + source.registerCorsConfiguration("/**", configuration) + return source + } +} \ No newline at end of file From fe6a4260ec44cbfaaf1ba9ed0ac02ddaccf4a12d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 11 Oct 2025 15:09:55 +0900 Subject: [PATCH 056/470] =?UTF-8?q?build=20:=20S3=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 5b4061c..9cd67c3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,8 @@ dependencies { runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5") // redis implementation("org.springframework.boot:spring-boot-starter-data-redis") + //s3 + implementation("org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE") } kotlin { From 73cd88044cb4ff7d8759b6d37750ea3fdaebf192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 11 Oct 2025 15:15:53 +0900 Subject: [PATCH 057/470] =?UTF-8?q?feat=20:=20S3=20presignedUrl=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/s3/config/S3DevConfig.kt | 31 +++++++ .../backend/global/s3/config/S3ProdConfig.kt | 20 +++++ .../global/s3/controller/S3Controller.kt | 35 ++++++++ .../onku/backend/global/s3/dto/GetS3UrlDto.kt | 6 ++ .../backend/global/s3/service/S3Service.kt | 86 +++++++++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt create mode 100644 src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt create mode 100644 src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt create mode 100644 src/main/kotlin/onku/backend/global/s3/dto/GetS3UrlDto.kt create mode 100644 src/main/kotlin/onku/backend/global/s3/service/S3Service.kt diff --git a/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt b/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt new file mode 100644 index 0000000..f3f22b3 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt @@ -0,0 +1,31 @@ +package onku.backend.global.s3.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import com.amazonaws.auth.AWSCredentials +import com.amazonaws.auth.AWSStaticCredentialsProvider +import com.amazonaws.auth.BasicAWSCredentials +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.AmazonS3ClientBuilder +import org.springframework.context.annotation.Profile + +@Configuration +@Profile("dev", "local") +class S3DevConfig( + @Value("\${cloud.aws.credentials.access-key}") + private val accessKey: String, + @Value("\${cloud.aws.credentials.secret-key}") + private val secretKey: String, + @Value("\${cloud.aws.region.static}") + private val region: String +) { + @Bean + fun amazonS3Client(): AmazonS3 { + val credentials: AWSCredentials = BasicAWSCredentials(accessKey, secretKey) + return AmazonS3ClientBuilder.standard() + .withCredentials(AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt b/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt new file mode 100644 index 0000000..64add6d --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt @@ -0,0 +1,20 @@ +package onku.backend.global.s3.config + +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.AmazonS3ClientBuilder +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile + +@Configuration +@Profile("prod") +class S3ProdConfig( + @Value("\${cloud.aws.region.static}") private val region: String +) { + @Bean + fun prodAmazonS3Client(): AmazonS3 = + AmazonS3ClientBuilder.standard() + .withRegion(region) + .build() +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt b/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt new file mode 100644 index 0000000..ddb9b3e --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt @@ -0,0 +1,35 @@ +package onku.backend.global.s3.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.response.SuccessResponse +import onku.backend.global.s3.dto.GetS3UrlDto +import onku.backend.global.s3.service.S3Service +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "S3 API", description = "Presigned Url 발급 API") +@RestController +@RequestMapping("/api/v1/s3") +class S3Controller( + private val s3Service : S3Service +) { + /** + * Todo 테스트용 컨트롤러임 나중에 지우기 + */ + @GetMapping("/postUrl") + @Operation(summary = "업로드 용 postUrl", description = "업로드 용 PresignedUrl을 반환합니다.") + fun postUrl(@CurrentMember member : Member, folderName : String, fileName : String) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(s3Service.getPostS3Url(member.id!!, fileName, folderName))) + } + + @GetMapping("/getUrl") + @Operation(summary = "조회 용 getUrl", description = "조회 용 PresignedUrl을 반환합니다.") + fun getUrl(@CurrentMember member : Member, keyName: String) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(s3Service.getGetS3Url(member.id!!, keyName))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/dto/GetS3UrlDto.kt b/src/main/kotlin/onku/backend/global/s3/dto/GetS3UrlDto.kt new file mode 100644 index 0000000..dce16a7 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/dto/GetS3UrlDto.kt @@ -0,0 +1,6 @@ +package onku.backend.global.s3.dto + +data class GetS3UrlDto( + val preSignedUrl: String, + val key: String +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt new file mode 100644 index 0000000..6e961c9 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -0,0 +1,86 @@ +package onku.backend.global.s3.service + +import com.amazonaws.HttpMethod +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.Headers +import com.amazonaws.services.s3.model.CannedAccessControlList +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest +import onku.backend.global.s3.dto.GetS3UrlDto +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.util.Date +import java.util.UUID + +@Service +class S3Service( + @Value("\${cloud.aws.s3.bucket}") + private val bucket : String, + private val amazonS3Client : AmazonS3 +) { + @Transactional(readOnly = true) + fun getPostS3Url(memberId: Long, filename: String, folderName : String): GetS3UrlDto { + // filename 설정하기(profile 경로 + 멤버ID + 랜덤 값) + val key = "$folderName/$memberId/${UUID.randomUUID()}/$filename" + + // url 유효기간 설정하기(1시간) + val expiration = getExpiration() + + // presigned url 생성하기 + val generatePresignedUrlRequest = getPostGeneratePresignedUrlRequest(key, expiration) + val url: URL = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest) + + // return + return GetS3UrlDto( + preSignedUrl = url.toExternalForm(), + key = key + ) + } + + @Transactional(readOnly = true) + fun getGetS3Url(memberId: Long, key: String): GetS3UrlDto { + val expiration = getExpiration() + + val generatePresignedUrlRequest = getGetGeneratePresignedUrlRequest(key, expiration) + val url: URL = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest) + + return GetS3UrlDto( + preSignedUrl = url.toExternalForm(), + key = key + ) + } + + /** post 용 URL 생성하는 메소드 */ + private fun getPostGeneratePresignedUrlRequest(fileName: String, expiration: Date): GeneratePresignedUrlRequest { + val request = GeneratePresignedUrlRequest(bucket, fileName) + .withMethod(HttpMethod.PUT) + .withKey(fileName) + .withExpiration(expiration) + + request.addRequestParameter( + Headers.S3_CANNED_ACL, + CannedAccessControlList.PublicRead.toString() + ) + return request + } + + /** get 용 URL 생성하는 메소드 */ + private fun getGetGeneratePresignedUrlRequest(key: String, expiration: Date): GeneratePresignedUrlRequest { + return GeneratePresignedUrlRequest(bucket, key) + .withMethod(HttpMethod.GET) + .withExpiration(expiration) + } + + companion object { + /** Presigned URL 만료시간 설정 (기본: 10분) */ + private fun getExpiration(): Date { + val expiration = Date() + val expTimeMillis = expiration.time + 1000 * 60 * 10 // 10분 + expiration.time = expTimeMillis + return expiration + } + } + + +} \ No newline at end of file From 41298aad0af3e09154e42d136edf6e2e3dff60df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 11 Oct 2025 15:40:16 +0900 Subject: [PATCH 058/470] =?UTF-8?q?chore=20:=20profile=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt b/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt index f3f22b3..55a65ff 100644 --- a/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt +++ b/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt @@ -11,7 +11,7 @@ import com.amazonaws.services.s3.AmazonS3ClientBuilder import org.springframework.context.annotation.Profile @Configuration -@Profile("dev", "local") +@Profile("dev", "local", "test") class S3DevConfig( @Value("\${cloud.aws.credentials.access-key}") private val accessKey: String, From a7f47584e0a18a143d2ab4985018ef180c3a7b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 11 Oct 2025 16:13:22 +0900 Subject: [PATCH 059/470] =?UTF-8?q?fix=20:=20contentType=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/exception/ErrorCode.kt | 3 ++- .../backend/global/s3/service/S3Service.kt | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt index 9f30088..ebeefc0 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -13,5 +13,6 @@ enum class ErrorCode( FORBIDDEN("COMMON403", "권한이 부족합니다.", HttpStatus.FORBIDDEN), INVALID_PARAMETER("COMMON422", "잘못된 파라미터입니다.", HttpStatus.UNPROCESSABLE_ENTITY), PARAMETER_VALIDATION_ERROR("COMMON422", "파라미터 검증 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), - PARAMETER_GRAMMAR_ERROR("COMMON422", "파라미터 문법 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY); + PARAMETER_GRAMMAR_ERROR("COMMON422", "파라미터 문법 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), + INVALID_FILE_EXTENSION("FILE001", "올바르지 않은 파일 확장자 입니다.", HttpStatus.BAD_REQUEST) } diff --git a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt index 6e961c9..d8e7b94 100644 --- a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -5,6 +5,8 @@ import com.amazonaws.services.s3.AmazonS3 import com.amazonaws.services.s3.Headers import com.amazonaws.services.s3.model.CannedAccessControlList import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode import onku.backend.global.s3.dto.GetS3UrlDto import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service @@ -27,8 +29,10 @@ class S3Service( // url 유효기간 설정하기(1시간) val expiration = getExpiration() + val contentType = guessContentType(filename) + // presigned url 생성하기 - val generatePresignedUrlRequest = getPostGeneratePresignedUrlRequest(key, expiration) + val generatePresignedUrlRequest = getPostGeneratePresignedUrlRequest(key, expiration, contentType) val url: URL = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest) // return @@ -41,8 +45,9 @@ class S3Service( @Transactional(readOnly = true) fun getGetS3Url(memberId: Long, key: String): GetS3UrlDto { val expiration = getExpiration() + val contentType = guessContentType(key) - val generatePresignedUrlRequest = getGetGeneratePresignedUrlRequest(key, expiration) + val generatePresignedUrlRequest = getGetGeneratePresignedUrlRequest(key, expiration, contentType) val url: URL = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest) return GetS3UrlDto( @@ -52,10 +57,11 @@ class S3Service( } /** post 용 URL 생성하는 메소드 */ - private fun getPostGeneratePresignedUrlRequest(fileName: String, expiration: Date): GeneratePresignedUrlRequest { + private fun getPostGeneratePresignedUrlRequest(fileName: String, expiration: Date, contentType : String): GeneratePresignedUrlRequest { val request = GeneratePresignedUrlRequest(bucket, fileName) .withMethod(HttpMethod.PUT) .withKey(fileName) + .withContentType(contentType) .withExpiration(expiration) request.addRequestParameter( @@ -66,12 +72,25 @@ class S3Service( } /** get 용 URL 생성하는 메소드 */ - private fun getGetGeneratePresignedUrlRequest(key: String, expiration: Date): GeneratePresignedUrlRequest { + private fun getGetGeneratePresignedUrlRequest(key: String, expiration: Date, contentType: String): GeneratePresignedUrlRequest { return GeneratePresignedUrlRequest(bucket, key) .withMethod(HttpMethod.GET) + .withContentType(contentType) .withExpiration(expiration) } + private fun guessContentType(filename: String): String { + val lower = filename.lowercase() + return when { + lower.endsWith(".jpg") || lower.endsWith(".jpeg") -> "image/jpeg" + lower.endsWith(".png") -> "image/png" + lower.endsWith(".gif") -> "image/gif" + lower.endsWith(".webp") -> "image/webp" + lower.endsWith(".pdf") -> "application/pdf" + else -> throw CustomException(ErrorCode.INVALID_FILE_EXTENSION) + } + } + companion object { /** Presigned URL 만료시간 설정 (기본: 10분) */ private fun getExpiration(): Date { From abca36ca3cb0db04f7814fcaee7f90990a623be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 11 Oct 2025 23:53:42 +0900 Subject: [PATCH 060/470] =?UTF-8?q?fix=20:=20Geturl=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=EC=97=90=20contentType=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/s3/service/S3Service.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt index d8e7b94..b6be24e 100644 --- a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -5,6 +5,7 @@ import com.amazonaws.services.s3.AmazonS3 import com.amazonaws.services.s3.Headers import com.amazonaws.services.s3.model.CannedAccessControlList import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest +import com.amazonaws.services.s3.model.ResponseHeaderOverrides import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode import onku.backend.global.s3.dto.GetS3UrlDto @@ -73,9 +74,11 @@ class S3Service( /** get 용 URL 생성하는 메소드 */ private fun getGetGeneratePresignedUrlRequest(key: String, expiration: Date, contentType: String): GeneratePresignedUrlRequest { + val overrides = ResponseHeaderOverrides() + .withContentType(contentType) return GeneratePresignedUrlRequest(bucket, key) .withMethod(HttpMethod.GET) - .withContentType(contentType) + .withResponseHeaders(overrides) .withExpiration(expiration) } From ccd364cfde7303cf226bb194c7ddf6f4a1bd1c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sun, 12 Oct 2025 11:38:09 +0900 Subject: [PATCH 061/470] =?UTF-8?q?build=20:=20aws=20sdk=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20#11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 9cd67c3..d182c9a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,7 +42,9 @@ dependencies { // redis implementation("org.springframework.boot:spring-boot-starter-data-redis") //s3 - implementation("org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE") + implementation("software.amazon.awssdk:s3:2.25.34") + implementation("software.amazon.awssdk:auth:2.25.34") + implementation("software.amazon.awssdk:regions:2.25.34") } kotlin { From 21ae691714ebc3c6a3b5443981403f30acb753c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sun, 12 Oct 2025 11:38:37 +0900 Subject: [PATCH 062/470] =?UTF-8?q?refactor=20:=20aws=20sdk=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=B0=EC=84=9C=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/s3/config/S3DevConfig.kt | 23 ++--- .../backend/global/s3/config/S3ProdConfig.kt | 12 ++- .../backend/global/s3/service/S3Service.kt | 91 +++++++------------ 3 files changed, 53 insertions(+), 73 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt b/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt index 55a65ff..b83eac7 100644 --- a/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt +++ b/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt @@ -3,12 +3,12 @@ package onku.backend.global.s3.config import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import com.amazonaws.auth.AWSCredentials -import com.amazonaws.auth.AWSStaticCredentialsProvider -import com.amazonaws.auth.BasicAWSCredentials -import com.amazonaws.services.s3.AmazonS3 -import com.amazonaws.services.s3.AmazonS3ClientBuilder import org.springframework.context.annotation.Profile +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.presigner.S3Presigner @Configuration @Profile("dev", "local", "test") @@ -20,12 +20,13 @@ class S3DevConfig( @Value("\${cloud.aws.region.static}") private val region: String ) { + private fun staticCreds() = + StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)) + @Bean - fun amazonS3Client(): AmazonS3 { - val credentials: AWSCredentials = BasicAWSCredentials(accessKey, secretKey) - return AmazonS3ClientBuilder.standard() - .withCredentials(AWSStaticCredentialsProvider(credentials)) - .withRegion(region) + fun s3Presigner(): S3Presigner = + S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(staticCreds()) .build() - } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt b/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt index 64add6d..9685f4b 100644 --- a/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt +++ b/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt @@ -1,11 +1,12 @@ package onku.backend.global.s3.config -import com.amazonaws.services.s3.AmazonS3 -import com.amazonaws.services.s3.AmazonS3ClientBuilder import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Profile +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.presigner.S3Presigner @Configuration @Profile("prod") @@ -13,8 +14,9 @@ class S3ProdConfig( @Value("\${cloud.aws.region.static}") private val region: String ) { @Bean - fun prodAmazonS3Client(): AmazonS3 = - AmazonS3ClientBuilder.standard() - .withRegion(region) + fun s3Presigner(): S3Presigner = + S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) .build() } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt index b6be24e..84edb5b 100644 --- a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -1,85 +1,68 @@ package onku.backend.global.s3.service -import com.amazonaws.HttpMethod -import com.amazonaws.services.s3.AmazonS3 -import com.amazonaws.services.s3.Headers -import com.amazonaws.services.s3.model.CannedAccessControlList -import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest -import com.amazonaws.services.s3.model.ResponseHeaderOverrides import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode import onku.backend.global.s3.dto.GetS3UrlDto import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import software.amazon.awssdk.services.s3.model.GetObjectRequest +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest import java.net.URL -import java.util.Date +import java.time.Duration import java.util.UUID @Service class S3Service( @Value("\${cloud.aws.s3.bucket}") private val bucket : String, - private val amazonS3Client : AmazonS3 + private val s3Presigner: S3Presigner ) { @Transactional(readOnly = true) fun getPostS3Url(memberId: Long, filename: String, folderName : String): GetS3UrlDto { - // filename 설정하기(profile 경로 + 멤버ID + 랜덤 값) val key = "$folderName/$memberId/${UUID.randomUUID()}/$filename" + val contentType = guessContentType(filename) - // url 유효기간 설정하기(1시간) - val expiration = getExpiration() + val putObjReq = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(contentType) + .build() - val contentType = guessContentType(filename) + val presignReq = PutObjectPresignRequest.builder() + .signatureDuration(DEFAULT_EXPIRE) + .putObjectRequest(putObjReq) + .build() - // presigned url 생성하기 - val generatePresignedUrlRequest = getPostGeneratePresignedUrlRequest(key, expiration, contentType) - val url: URL = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest) + val presigned = s3Presigner.presignPutObject(presignReq) + val url: URL = presigned.url() - // return - return GetS3UrlDto( - preSignedUrl = url.toExternalForm(), - key = key - ) + return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key) } @Transactional(readOnly = true) fun getGetS3Url(memberId: Long, key: String): GetS3UrlDto { - val expiration = getExpiration() val contentType = guessContentType(key) - val generatePresignedUrlRequest = getGetGeneratePresignedUrlRequest(key, expiration, contentType) - val url: URL = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest) - - return GetS3UrlDto( - preSignedUrl = url.toExternalForm(), - key = key - ) - } + // 응답 Content-Type을 강제로 지정하고 싶으면 responseContentType 사용 + val getObjReq = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .responseContentType(contentType) + .build() - /** post 용 URL 생성하는 메소드 */ - private fun getPostGeneratePresignedUrlRequest(fileName: String, expiration: Date, contentType : String): GeneratePresignedUrlRequest { - val request = GeneratePresignedUrlRequest(bucket, fileName) - .withMethod(HttpMethod.PUT) - .withKey(fileName) - .withContentType(contentType) - .withExpiration(expiration) + val presignReq = GetObjectPresignRequest.builder() + .signatureDuration(DEFAULT_EXPIRE) + .getObjectRequest(getObjReq) + .build() - request.addRequestParameter( - Headers.S3_CANNED_ACL, - CannedAccessControlList.PublicRead.toString() - ) - return request - } + val presigned = s3Presigner.presignGetObject(presignReq) + val url: URL = presigned.url() - /** get 용 URL 생성하는 메소드 */ - private fun getGetGeneratePresignedUrlRequest(key: String, expiration: Date, contentType: String): GeneratePresignedUrlRequest { - val overrides = ResponseHeaderOverrides() - .withContentType(contentType) - return GeneratePresignedUrlRequest(bucket, key) - .withMethod(HttpMethod.GET) - .withResponseHeaders(overrides) - .withExpiration(expiration) + return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key) } private fun guessContentType(filename: String): String { @@ -95,13 +78,7 @@ class S3Service( } companion object { - /** Presigned URL 만료시간 설정 (기본: 10분) */ - private fun getExpiration(): Date { - val expiration = Date() - val expTimeMillis = expiration.time + 1000 * 60 * 10 // 10분 - expiration.time = expTimeMillis - return expiration - } + private val DEFAULT_EXPIRE: Duration = Duration.ofMinutes(10) } From baecb8f94c1e862ded80dae57d81de433ea6679e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 15:27:34 +0900 Subject: [PATCH 063/470] =?UTF-8?q?feat:=20Attendance,=20Session=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/attendance/Attendance.kt | 32 ++++++++++++++ .../onku/backend/domain/session/Session.kt | 44 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/Attendance.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/Session.kt 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..af22270 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt @@ -0,0 +1,32 @@ +package onku.backend.domain.attendance + +import jakarta.persistence.* +import onku.backend.domain.attendance.enums.AttendanceStatus +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: AttendanceStatus +) 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..f070d93 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/Session.kt @@ -0,0 +1,44 @@ +package onku.backend.domain.session + +import jakarta.persistence.* +import onku.backend.domain.session.enums.SessionCategory +import java.time.LocalDateTime + +@Entity +@Table(name = "session") +class Session( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "session_id") + val id: Long? = null, + + @Column(name = "title", nullable = false, length = 255) + val title: String, + + @Column(name = "start_time", nullable = false) + val startTime: LocalDateTime, + + @Column(name = "end_time", nullable = false) + val endTime: LocalDateTime, + + @Column(name = "place", nullable = false, length = 255) + val place: String, + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 32) + val category: SessionCategory, + + @Column(name = "week", nullable = false) + val week: Long, + + @Column(name = "is_reward", nullable = false) + val isReward: Boolean, + + @Column(name = "open_grace_seconds", nullable = false) // 세션 시작 이전 N초부터 출석 허용 + val openGraceSeconds: Long = 0, + + @Column(name = "close_grace_seconds", nullable = false) // 세션 종료 이후 N초까지 출석 허용 + val closeGraceSeconds: Long = 0, + + @Column(name = "late_threshold_time", nullable = false) // 지각 기준 시각 + val lateThresholdTime: LocalDateTime +) From 585700f3dd061b9e4231cab2e9487abad95e6321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 15:28:37 +0900 Subject: [PATCH 064/470] =?UTF-8?q?feat:=20attendance=20dto=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/dto/AttendanceRequest.kt | 5 +++++ .../domain/attendance/dto/AttendanceResponse.kt | 12 ++++++++++++ .../domain/attendance/dto/AttendanceTokenResponse.kt | 8 ++++++++ 3 files changed, 25 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenResponse.kt 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..984a179 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.attendance.dto + +import onku.backend.domain.attendance.enums.AttendanceStatus +import java.time.LocalDateTime + +data class AttendanceResponse( + val memberId: Long, + val memberName: String, + val sessionId: Long, + val state: AttendanceStatus, + val scannedAt: LocalDateTime +) \ No newline at end of file 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..5e405ef --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenResponse.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.attendance.dto + +import java.time.LocalDateTime + +data class AttendanceTokenResponse ( + val token: String, + val expAt: LocalDateTime +) \ No newline at end of file From efc564fa46676508c94996e705b9e4b12047f3ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 13 Oct 2025 15:50:17 +0900 Subject: [PATCH 065/470] =?UTF-8?q?chore=20:=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=9C=A0=ED=8B=B8=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/time/TimeRangeUtil.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt diff --git a/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt b/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt new file mode 100644 index 0000000..5973e27 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt @@ -0,0 +1,18 @@ +package onku.backend.global.time + +import java.time.LocalDateTime +import java.time.ZoneId + +object TimeRangeUtil { + data class MonthRange( + val startOfMonth: LocalDateTime, + val startOfNextMonth: LocalDateTime + ) + + fun getCurrentMonthRange(zoneId: ZoneId = ZoneId.of("Asia/Seoul")): MonthRange { + val now = LocalDateTime.now(zoneId) + val startOfMonth = now.toLocalDate().withDayOfMonth(1).atStartOfDay() + val startOfNextMonth = startOfMonth.plusMonths(1) + return MonthRange(startOfMonth, startOfNextMonth) + } +} \ No newline at end of file From ec1e89e6a65b24a68a6f57ffd83209566eb9a9a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 13 Oct 2025 15:50:35 +0900 Subject: [PATCH 066/470] =?UTF-8?q?chore=20:=20baseEntity=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/Member.kt | 3 ++- .../onku/backend/global/entity/BaseEntity.kt | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/onku/backend/global/entity/BaseEntity.kt diff --git a/src/main/kotlin/onku/backend/domain/member/Member.kt b/src/main/kotlin/onku/backend/domain/member/Member.kt index 7e7fed7..4367482 100644 --- a/src/main/kotlin/onku/backend/domain/member/Member.kt +++ b/src/main/kotlin/onku/backend/domain/member/Member.kt @@ -4,6 +4,7 @@ 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( @@ -31,7 +32,7 @@ class Member( @Enumerated(EnumType.STRING) @Column(name = "approval", nullable = false, length = 20) var approval: ApprovalStatus = ApprovalStatus.PENDING -) { +) : BaseEntity() { fun approve() { this.approval = ApprovalStatus.APPROVED } fun reject() { this.approval = ApprovalStatus.REJECTED } fun onboarded() { this.hasInfo = true } 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 From 4a9854156b45a1c9c40c58a2ce97bc172493e52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 13 Oct 2025 15:50:51 +0900 Subject: [PATCH 067/470] =?UTF-8?q?chore=20:=20s3=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=83=81=EC=88=98=ED=99=94=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt diff --git a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt new file mode 100644 index 0000000..a033d48 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt @@ -0,0 +1,6 @@ +package onku.backend.global.s3.enums + +enum class FolderName{ + KUPICK_APPLICATION, + KUPICK_VIEW; +} \ No newline at end of file From 06e939385d3e1cb670c0aaec22659acd678efe5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 13 Oct 2025 15:51:18 +0900 Subject: [PATCH 068/470] =?UTF-8?q?feat=20:=20=ED=81=90=ED=94=BD(=ED=95=99?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EC=A0=84=EC=9A=A9)=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20api=20=EC=9E=91=EC=84=B1=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/kupick/Kupick.kt | 65 +++++++++++++++++++ .../kupick/controller/KupickController.kt | 59 +++++++++++++++++ .../kupick/dto/ViewMyKupickResponseDto.kt | 23 +++++++ .../domain/kupick/facade/KupickFacade.kt | 45 +++++++++++++ .../kupick/repository/KupickRepository.kt | 45 +++++++++++++ .../repository/projection/KupickUrls.kt | 10 +++ .../domain/kupick/service/KupickService.kt | 52 +++++++++++++++ .../backend/global/exception/ErrorCode.kt | 3 +- .../global/s3/dto/GetPreSignedUrlDto.kt | 5 ++ 9 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/onku/backend/domain/kupick/Kupick.kt create mode 100644 src/main/kotlin/onku/backend/domain/kupick/controller/KupickController.kt create mode 100644 src/main/kotlin/onku/backend/domain/kupick/dto/ViewMyKupickResponseDto.kt create mode 100644 src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt create mode 100644 src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt create mode 100644 src/main/kotlin/onku/backend/domain/kupick/repository/projection/KupickUrls.kt create mode 100644 src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt create mode 100644 src/main/kotlin/onku/backend/global/s3/dto/GetPreSignedUrlDto.kt 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..d444fc3 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt @@ -0,0 +1,65 @@ +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 + @JoinColumn(name = "member_id") + val member : Member, + + @Column(name = "summit_date") + var summitDate: 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 + + ) : BaseEntity() { + companion object { + fun createApplication( + member: Member, + applicationImageUrl: String, + applicationDate: LocalDateTime? + ): Kupick { + return Kupick( + member = member, + applicationImageUrl = applicationImageUrl, + applicationDate = applicationDate, + summitDate = applicationDate + ) + } + } + + fun summitView(viewImageUrl: String, nowDate: LocalDateTime) { + this.viewImageUrl = viewImageUrl + this.viewDate = nowDate + this.summitDate = nowDate + } + + fun updateApplication(newUrl: String, newDate: LocalDateTime) { + this.applicationImageUrl = newUrl + this.applicationDate = newDate + this.summitDate = newDate + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/controller/KupickController.kt b/src/main/kotlin/onku/backend/domain/kupick/controller/KupickController.kt new file mode 100644 index 0000000..2b47959 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/KupickController.kt @@ -0,0 +1,59 @@ +package onku.backend.domain.kupick.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.kupick.dto.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.GetPreSignedUrlDto +import org.springframework.http.HttpStatus +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.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/kupick") +@Tag(name = "큐픽 API", description = "큐픽 관련 CRUD API") +class KupickController( + private val kupickFacade: KupickFacade +) { + @GetMapping("/application") + @ResponseStatus(HttpStatus.OK) + @Operation( + summary = "큐픽 신청 서류 제출", + description = "큐픽 신청용 서류 제출 signedUrl 반환" + ) + fun summitApplication( + @CurrentMember member : Member, fileName : String + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.summitApplication(member, fileName))) + } + + @GetMapping("/view") + @ResponseStatus(HttpStatus.OK) + @Operation( + summary = "큐픽 시청 서류 제출", + description = "큐픽 시청 증빙 서류 제출 signedUrl 반환" + ) + fun summitView( + @CurrentMember member: Member, fileName: String + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.summitView(member, fileName))) + } + + @GetMapping("/my") + @ResponseStatus(HttpStatus.OK) + @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/ViewMyKupickResponseDto.kt b/src/main/kotlin/onku/backend/domain/kupick/dto/ViewMyKupickResponseDto.kt new file mode 100644 index 0000000..f84a7ff --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/dto/ViewMyKupickResponseDto.kt @@ -0,0 +1,23 @@ +package onku.backend.domain.kupick.dto + +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..3df76ff --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -0,0 +1,45 @@ +package onku.backend.domain.kupick.facade + +import onku.backend.domain.kupick.dto.ViewMyKupickResponseDto +import onku.backend.domain.kupick.service.KupickService +import onku.backend.domain.member.Member +import onku.backend.global.s3.dto.GetPreSignedUrlDto +import onku.backend.global.s3.enums.FolderName +import onku.backend.global.s3.service.S3Service +import org.springframework.stereotype.Component + +@Component +class KupickFacade( + private val s3Service: S3Service, + private val kupickService: KupickService +) { + fun summitApplication(member: Member, fileName: String): GetPreSignedUrlDto { + val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_APPLICATION.name) + kupickService.summitApplication(member, signedUrlDto.key) + return GetPreSignedUrlDto( + signedUrlDto.preSignedUrl + ) + } + + fun summitView(member: Member, fileName: String): GetPreSignedUrlDto { + val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_VIEW.name) + kupickService.summitView(member, signedUrlDto.key) + return GetPreSignedUrlDto( + signedUrlDto.preSignedUrl + ) + } + + 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() + ) + } +} \ 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..025a0c4 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt @@ -0,0 +1,45 @@ +package onku.backend.domain.kupick.repository + +import onku.backend.domain.kupick.Kupick +import onku.backend.domain.kupick.repository.projection.KupickUrls +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? +} \ 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/service/KupickService.kt b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt new file mode 100644 index 0000000..59d1df6 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -0,0 +1,52 @@ +package onku.backend.domain.kupick.service + +import onku.backend.domain.kupick.Kupick +import onku.backend.domain.kupick.repository.KupickRepository +import onku.backend.domain.kupick.repository.projection.KupickUrls +import onku.backend.domain.member.Member +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode +import onku.backend.global.time.TimeRangeUtil +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class KupickService( + private val kupickRepository: KupickRepository +) { + @Transactional + fun summitApplication(member: Member, applicationUrl : String) { + val monthObject = TimeRangeUtil.getCurrentMonthRange() + val existing = kupickRepository.findFirstByMemberAndApplicationDateBetween( + member, monthObject.startOfMonth, monthObject.startOfNextMonth + ) + val now = LocalDateTime.now() + existing + ?.updateApplication(applicationUrl, now) + ?: kupickRepository.save( + Kupick.createApplication(member, applicationUrl, LocalDateTime.now()) + ) + } + + @Transactional + fun summitView(member: Member, viewUrl: String) { + val monthObject = TimeRangeUtil.getCurrentMonthRange() + val now = LocalDateTime.now() + val kupick = kupickRepository.findThisMonthByMember( + member, + monthObject.startOfMonth, + monthObject.startOfNextMonth) ?: throw CustomException(ErrorCode.KUPICK_APPLICATION_FIRST) + kupick.summitView(viewUrl, now) + } + + @Transactional(readOnly = true) + fun viewMyKupick(member: Member) : KupickUrls? { + val monthObject = TimeRangeUtil.getCurrentMonthRange() + return kupickRepository.findUrlsForMemberInMonth( + member, + monthObject.startOfMonth, + monthObject.startOfNextMonth + ) + } +} \ 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 index ebeefc0..c5133bb 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -14,5 +14,6 @@ enum class ErrorCode( INVALID_PARAMETER("COMMON422", "잘못된 파라미터입니다.", HttpStatus.UNPROCESSABLE_ENTITY), PARAMETER_VALIDATION_ERROR("COMMON422", "파라미터 검증 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), PARAMETER_GRAMMAR_ERROR("COMMON422", "파라미터 문법 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), - INVALID_FILE_EXTENSION("FILE001", "올바르지 않은 파일 확장자 입니다.", HttpStatus.BAD_REQUEST) + INVALID_FILE_EXTENSION("S3001", "올바르지 않은 파일 확장자 입니다.", HttpStatus.BAD_REQUEST), + KUPICK_APPLICATION_FIRST("kupick001", "큐픽 신청부터 진행해주세요", HttpStatus.BAD_REQUEST); } diff --git a/src/main/kotlin/onku/backend/global/s3/dto/GetPreSignedUrlDto.kt b/src/main/kotlin/onku/backend/global/s3/dto/GetPreSignedUrlDto.kt new file mode 100644 index 0000000..cb8de25 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/dto/GetPreSignedUrlDto.kt @@ -0,0 +1,5 @@ +package onku.backend.global.s3.dto + +data class GetPreSignedUrlDto ( + val preSignedUrl : String +) \ No newline at end of file From 0711cdf1732295eaf0045b39a7336c54a7ed91fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 15:57:07 +0900 Subject: [PATCH 069/470] =?UTF-8?q?feat:=20TokenData=20DTO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(Redis=20=ED=86=A0=ED=81=B0=20=EC=A7=81=EB=A0=AC?= =?UTF-8?q?=ED=99=94/=EC=97=AD=EC=A7=81=EB=A0=AC=ED=99=94=EC=9A=A9)=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/redis/dto/TokenData.kt | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/redis/dto/TokenData.kt diff --git a/src/main/kotlin/onku/backend/global/redis/dto/TokenData.kt b/src/main/kotlin/onku/backend/global/redis/dto/TokenData.kt new file mode 100644 index 0000000..8428148 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/redis/dto/TokenData.kt @@ -0,0 +1,39 @@ +package onku.backend.global.redis.dto + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +data class TokenData( + val memberId: Long, + val issuedAt: LocalDateTime, + val expAt: LocalDateTime, + val used: Boolean +) { + // TokenData 객체를 문자열로 직렬화해서 Redis에 저장 + fun toPayload(zone: ZoneId = ZoneId.of("UTC")): String { + val issuedMs = issuedAt.atZone(zone).toInstant().toEpochMilli() + val expMs = expAt.atZone(zone).toInstant().toEpochMilli() + val usedFlag = if (used) 1 else 0 + return "$memberId|$issuedMs|$expMs|$usedFlag" + } + + companion object { + // redis에서 꺼낸 값을 TokenData 객체로 변경 + fun parse(raw: String?, zone: ZoneId = ZoneId.of("UTC")): TokenData? = runCatching { + val s = raw?.trim().orEmpty() + if (s.isEmpty()) return null + val p = s.split('|') + if (p.size != 4) return null + + val memberId = p[0].toLong() + val issuedMs = p[1].toLong() + val expMs = p[2].toLong() + val used = p[3] == "1" + + val issuedAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(issuedMs), zone) + val expAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(expMs), zone) + TokenData(memberId, issuedAt, expAt, used) + }.getOrNull() + } +} From 72021da6dab554692e0b78b7102703ccc216dd95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 15:58:40 +0900 Subject: [PATCH 070/470] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9A=A9=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/redis/util/TokenGenerator.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/redis/util/TokenGenerator.kt diff --git a/src/main/kotlin/onku/backend/global/redis/util/TokenGenerator.kt b/src/main/kotlin/onku/backend/global/redis/util/TokenGenerator.kt new file mode 100644 index 0000000..9c6df6b --- /dev/null +++ b/src/main/kotlin/onku/backend/global/redis/util/TokenGenerator.kt @@ -0,0 +1,15 @@ +package onku.backend.global.redis.util + +import org.springframework.stereotype.Component +import java.security.SecureRandom +import java.util.Base64 + +@Component +class TokenGenerator { + private val random = SecureRandom() + fun generateOpaqueToken(bytes: Int = 32): String { + val buf = ByteArray(bytes) + random.nextBytes(buf) + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf) + } +} From a1aebb150063eb0799007bdb4e45861c8cd4f108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:03:12 +0900 Subject: [PATCH 071/470] =?UTF-8?q?refactor:=20TokenGenerator=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EC=9D=98=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/util/TokenGenerator.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/util/TokenGenerator.kt diff --git a/src/main/kotlin/onku/backend/global/util/TokenGenerator.kt b/src/main/kotlin/onku/backend/global/util/TokenGenerator.kt new file mode 100644 index 0000000..94c27a9 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/util/TokenGenerator.kt @@ -0,0 +1,15 @@ +package onku.backend.global.util + +import org.springframework.stereotype.Component +import java.security.SecureRandom +import java.util.Base64 + +@Component +class TokenGenerator { + private val random = SecureRandom() + fun generateOpaqueToken(bytes: Int = 32): String { + val buf = ByteArray(bytes) + random.nextBytes(buf) + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf) + } +} From 5c74eec6ad0cd48e5c83159a7958d5254083fd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:05:23 +0900 Subject: [PATCH 072/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=B0=9C=EA=B8=89,=20=EA=B5=90=EC=B2=B4,=20?= =?UTF-8?q?=EC=86=8C=EB=B9=84=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/redis/AttendanceTokenCacheUtil.kt | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/redis/AttendanceTokenCacheUtil.kt diff --git a/src/main/kotlin/onku/backend/global/redis/AttendanceTokenCacheUtil.kt b/src/main/kotlin/onku/backend/global/redis/AttendanceTokenCacheUtil.kt new file mode 100644 index 0000000..a3e4e8c --- /dev/null +++ b/src/main/kotlin/onku/backend/global/redis/AttendanceTokenCacheUtil.kt @@ -0,0 +1,86 @@ +package onku.backend.global.redis + +import onku.backend.global.redis.dto.TokenData +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.dao.DataAccessException +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.script.DefaultRedisScript +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class AttendanceTokenCacheUtil( + private val redis: StringRedisTemplate, + @Qualifier("attendanceSwapScript") + private val swapScript: DefaultRedisScript, + @Qualifier("attendanceConsumeScript") + private val consumeScript: DefaultRedisScript +) { + private val zone: ZoneId = ZoneId.of("UTC") + + private companion object { + private const val TOKEN_PREFIX = "attendance:token:" + private const val MEMBER_PREFIX = "attendance:member:" + private const val ACTIVE_SUFFIX = ":active" + + fun tokenKey(token: String) = "$TOKEN_PREFIX$token" + fun memberKey(memberId: Long) = "$MEMBER_PREFIX$memberId$ACTIVE_SUFFIX" + } + + private fun execScript( + script: DefaultRedisScript, + keys: List, + vararg args: String + ): T? = try { + redis.execute(script, keys, *args) + } catch (e: DataAccessException) { + null + } + + /** + * 새 토큰을 발급하고 활성 토큰을 교체 + * 이전 활성 토큰이 있으면 used=1 + TTL 상한(usedGraceSeconds)으로 수정해서 active token은 단일이도록 보장 + */ + fun putAsActiveSingle( + memberId: Long, + token: String, + issuedAt: LocalDateTime, + expAt: LocalDateTime, + ttlSeconds: Long, + usedGraceSeconds: Long? = null + ) { + val payload = TokenData(memberId, issuedAt, expAt, used = false).toPayload(zone) + val keys = listOf( + memberKey(memberId), // KEYS[1] + TOKEN_PREFIX, // KEYS[2] + tokenKey(token) // KEYS[3] + ) + val baseArgs = mutableListOf( + payload, // ARGV[1] + ttlSeconds.toString(), // ARGV[2] + token // ARGV[3] + ) + usedGraceSeconds?.let { baseArgs.add(it.toString()) } + + execScript(swapScript, keys, *baseArgs.toTypedArray()) + ?: error("Failed to execute swapScript for token=$token") + } + + /** + * 토큰을 소비(출석체크 성공)하면서 used=1로 수정하고 TTL을 상한(usedGraceSeconds) 이하로 수정 + */ + fun consumeToken(token: String, usedGraceSeconds: Long = 30): TokenData? { + val keys = listOf(tokenKey(token)) + val raw: String? = execScript(consumeScript, keys, usedGraceSeconds.toString()) + return TokenData.parse(raw, zone) + } + + /** 멤버의 현재 활성 토큰 문자열을 조회 */ + fun getActiveTokenOf(memberId: Long): String? = + redis.opsForValue().get(memberKey(memberId)) + + /** 토큰 payload 확인 → 해당 member가 이미 세션에 출석했는지 여부를 검사하기 위해 */ + fun peek(token: String): TokenData? = + TokenData.parse(redis.opsForValue().get(tokenKey(token)), zone) +} From 339fab4fb95810de5941582a419ba0573c57ac7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:07:44 +0900 Subject: [PATCH 073/470] =?UTF-8?q?feat:=20consume=5Ftoken(=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=82=AC=EC=9A=A9=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20TTL=20=EC=83=81=ED=95=9C=20=EC=A0=81=EC=9A=A9),=20issue=5Fto?= =?UTF-8?q?ken=20(=ED=99=9C=EC=84=B1=20=ED=86=A0=ED=81=B0=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=EC=84=B1=20=EB=B3=B4=EC=9E=A5)=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/redis/lua/consume_token.lua | 39 +++++++++++++ .../backend/global/redis/lua/issue_token.lua | 58 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/redis/lua/consume_token.lua create mode 100644 src/main/kotlin/onku/backend/global/redis/lua/issue_token.lua diff --git a/src/main/kotlin/onku/backend/global/redis/lua/consume_token.lua b/src/main/kotlin/onku/backend/global/redis/lua/consume_token.lua new file mode 100644 index 0000000..26c61b7 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/redis/lua/consume_token.lua @@ -0,0 +1,39 @@ +-- KEYS[1] = tokenKey ("attendance:token:{token}") +-- ARGV[1] = usedGraceSeconds (e.g., "30") + +local tokenKey = KEYS[1] +local capMs = (tonumber(ARGV[1]) or 30) * 1000 + +local function parse_payload(v) + if not v then return nil end + local parts = {} + for p in string.gmatch(v, '([^|]+)') do table.insert(parts, p) end + if #parts ~= 4 then return nil end + return parts +end + +local function adjust_ttl(key, ttlms, cap) + if ttlms <= 0 or ttlms > cap then + redis.call('PEXPIRE', key, cap) + else + redis.call('PEXPIRE', key, ttlms) + end +end + +local v = redis.call('GET', tokenKey) +if not v then return nil end + +local parts = parse_payload(v) +if not parts then return nil end + +local ttlms = redis.call('PTTL', tokenKey) + +if parts[4] ~= '1' then + parts[4] = '1' + local updated = table.concat(parts, '|') + redis.call('SET', tokenKey, updated) +end + +adjust_ttl(tokenKey, ttlms, capMs) + +return v diff --git a/src/main/kotlin/onku/backend/global/redis/lua/issue_token.lua b/src/main/kotlin/onku/backend/global/redis/lua/issue_token.lua new file mode 100644 index 0000000..709b402 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/redis/lua/issue_token.lua @@ -0,0 +1,58 @@ +-- KEYS: +-- [1] memberKey : "attendance:member:{memberId}:active" +-- [2] tokenPrefix : "attendance:token:" +-- [3] newTokenKey : "attendance:token:{newToken}" +-- +-- ARGV: +-- [1] payload : "memberId|issuedMs|expMs|usedFlag" +-- [2] ttlSeconds : new token TTL (seconds) +-- [3] newToken : new token string +-- [4] usedGraceSeconds : optional, default 30 (seconds) + +local memberKey = KEYS[1] +local tokenPrefix = KEYS[2] +local newTokenKey = KEYS[3] +local payload = ARGV[1] +local ttlSeconds = tonumber(ARGV[2]) +local newToken = ARGV[3] +local usedGraceSeconds = tonumber(ARGV[4]) or 30 +local capMs = usedGraceSeconds * 1000 + +local function parse_payload(v) + if not v then return nil end + local parts = {} + for p in string.gmatch(v, '([^|]+)') do table.insert(parts, p) end + if #parts ~= 4 then return nil end + return parts +end + +local function adjust_ttl(key, ttlms, cap) + if ttlms <= 0 or ttlms > cap then + redis.call('PEXPIRE', key, cap) + else + redis.call('PEXPIRE', key, ttlms) + end +end + +local oldToken = redis.call('GET', memberKey) +if oldToken then + local oldTokenKey = tokenPrefix .. oldToken + local oldVal = redis.call('GET', oldTokenKey) + if oldVal then + local parts = parse_payload(oldVal) + if parts then + local ttlms = redis.call('PTTL', oldTokenKey) + if parts[4] ~= '1' then + parts[4] = '1' + local updatedVal = table.concat(parts, '|') + redis.call('SET', oldTokenKey, updatedVal) + end + adjust_ttl(oldTokenKey, ttlms, capMs) + end + end +end + +redis.call('SET', newTokenKey, payload, 'EX', ttlSeconds) +redis.call('SET', memberKey, newToken, 'EX', ttlSeconds) + +return 'OK' From 90a5e9653fb1486d3d917cc6c96f3c2779f0fa08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:08:25 +0900 Subject: [PATCH 074/470] =?UTF-8?q?feat:=20.lua=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=A0=81=EC=9A=A9=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20config=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/config/RedisLuaConfig.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/config/RedisLuaConfig.kt 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..7ccdf70 --- /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("onku/backend/global/redis/lua/issue_token.lua")) + setResultType(String::class.java) + } + + @Bean + fun attendanceConsumeScript(): DefaultRedisScript = + DefaultRedisScript().apply { + setLocation(ClassPathResource("onku/backend/global/redis/lua/consume_token.lua")) + setResultType(String::class.java) + } +} From 2a3079e5574cef141ed68b341b646af04f1318de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:09:25 +0900 Subject: [PATCH 075/470] =?UTF-8?q?feat:=20=EC=8B=9C=EA=B0=84=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=ED=86=B5=EC=9D=BC=EC=84=B1=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20UTC=20=EA=B8=B0=EC=A4=80=20config=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/global/config/TimeConfig.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/config/TimeConfig.kt 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..63c9fda --- /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.systemUTC() +} \ No newline at end of file From ee9b569ded608fdf5eaad7ed55456c363f02a537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:09:58 +0900 Subject: [PATCH 076/470] =?UTF-8?q?feat:=20session=20=EC=A2=85=EB=A5=98?= =?UTF-8?q?=EB=A5=BC=20=EB=82=98=ED=83=80=EB=82=B4=EB=8A=94=20enum=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/session/enums/SessionCategory.kt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt 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..d7d9974 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt @@ -0,0 +1,3 @@ +package onku.backend.domain.session.enums + +enum class SessionCategory { GENERAL, HOLIDAY } \ No newline at end of file From 1c51b6e39426a77fbb28d335287094674526fe6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:10:22 +0900 Subject: [PATCH 077/470] =?UTF-8?q?feat:=20=ED=98=84=EC=9E=AC=20=EC=97=B4?= =?UTF-8?q?=EB=A0=A4=EC=9E=88=EB=8A=94=20=EC=84=B8=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/repository/SessionRepository.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt 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..42f997a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -0,0 +1,23 @@ +package onku.backend.domain.session.repository + +import onku.backend.domain.session.Session +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface SessionRepository : CrudRepository { + + @Query( + value = """ + SELECT * + FROM session s + WHERE :now BETWEEN TIMESTAMPADD(SECOND, -s.open_grace_seconds, s.start_time) + AND TIMESTAMPADD(SECOND, s.close_grace_seconds, s.end_time) + ORDER BY s.start_time DESC + LIMIT 1 + """, + nativeQuery = true + ) + fun findOpenSession(@Param("now") now: LocalDateTime): Session? +} From be17265ddbdce9b579ffdb2fefce1f2ee73e6ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:10:47 +0900 Subject: [PATCH 078/470] =?UTF-8?q?feat:=20attendance=20errorcode=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/AttendanceErrorCode.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/AttendanceErrorCode.kt 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..c24497d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/AttendanceErrorCode.kt @@ -0,0 +1,14 @@ +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), +} From e30945171299d146bcfffbc158b5d3d8365f9915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:11:14 +0900 Subject: [PATCH 079/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=EA=B4=80=EB=A6=AC=EC=9A=A9=20enum=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/attendance/enums/AttendanceStatus.kt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt diff --git a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt new file mode 100644 index 0000000..cde8d72 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt @@ -0,0 +1,3 @@ +package onku.backend.domain.attendance.enums + +enum class AttendanceStatus { PRESENT, ABSENT, LATE } \ No newline at end of file From c64bbcf6bc342111fce5f8b1502b76ddb828ee7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:11:59 +0900 Subject: [PATCH 080/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=B0=BE=EA=B8=B0=20=EB=B0=8F=20insert=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/AttendanceRepository.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt 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..ed7ee54 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt @@ -0,0 +1,28 @@ +package onku.backend.domain.attendance.repository + +import onku.backend.domain.attendance.Attendance +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.LocalDateTime + +interface AttendanceRepository : CrudRepository { + + fun existsBySessionIdAndMemberId(sessionId: Long, memberId: Long): Boolean + + @Modifying + @Query( + value = """ + INSERT INTO attendance (session_id, member_id, status, attendance_time) + VALUES (:sessionId, :memberId, :status, :attendanceTime) + """, + nativeQuery = true + ) + fun insertOnly( + @Param("sessionId") sessionId: Long, + @Param("memberId") memberId: Long, + @Param("status") status: String, + @Param("attendanceTime") attendanceTime: LocalDateTime + ): Int +} From 27b4a26ae39e3fbd05e34305faac532a4f02867c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:13:43 +0900 Subject: [PATCH 081/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=B0=9C=EA=B8=89=20=EB=B0=8F=20=EC=B6=9C=EC=84=9D?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/service/AttendanceService.kt | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt 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..65efee2 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -0,0 +1,94 @@ +package onku.backend.domain.attendance.service + +import onku.backend.domain.attendance.AttendanceErrorCode +import onku.backend.domain.attendance.dto.* +import onku.backend.domain.attendance.enums.AttendanceStatus +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.member.Member +import onku.backend.domain.member.enums.Role +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode +import onku.backend.global.redis.AttendanceTokenCacheUtil +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: AttendanceTokenCacheUtil, + private val sessionRepository: SessionRepository, + private val attendanceRepository: AttendanceRepository, + private val memberProfileRepository: MemberProfileRepository, + private val tokenGenerator: TokenGenerator, + private val clock: Clock +) { + private val ttlSeconds: Long = 15L + + @Transactional(readOnly = true) + fun issueAttendanceTokenFor(member: Member): AttendanceTokenResponse { + if (member.role != Role.USER && member.role != Role.ADMIN) { + throw CustomException(ErrorCode.FORBIDDEN) + } + + val now = LocalDateTime.now(clock) + val expAt = now.plusSeconds(ttlSeconds) + val token = tokenGenerator.generateOpaqueToken() + + tokenCache.putAsActiveSingle(member.id!!, token, now, expAt, ttlSeconds) + return AttendanceTokenResponse(token = token, expAt = expAt) + } + + @Transactional + fun scanAndRecordBy(admin: Member, token: String): AttendanceResponse { + if (admin.role != Role.ADMIN) { + throw CustomException(ErrorCode.FORBIDDEN) + } + + val now = LocalDateTime.now(clock) + + val session = sessionRepository.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" + + val already = attendanceRepository.existsBySessionIdAndMemberId(session.id!!, memberId) + if (already) { + throw CustomException(AttendanceErrorCode.ATTENDANCE_ALREADY_RECORDED) + } + + val consumed = tokenCache.consumeToken(token) + ?: throw CustomException(ErrorCode.UNAUTHORIZED) + + val state = + if (now.isAfter(session.lateThresholdTime)) AttendanceStatus.LATE + else AttendanceStatus.PRESENT + + try { + attendanceRepository.insertOnly( + sessionId = session.id!!, + memberId = memberId, + status = state.name, + attendanceTime = now + ) + } catch (e: DataIntegrityViolationException) { + throw CustomException(AttendanceErrorCode.ATTENDANCE_ALREADY_RECORDED) + } + + return AttendanceResponse( + memberId = memberId, + memberName = memberName, + sessionId = session.id!!, + state = state, + scannedAt = now + ) + } +} From 1992206dead2e38646264b80bbe6a7a8a5f659ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:14:36 +0900 Subject: [PATCH 082/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AttendanceController.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt diff --git a/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt b/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt new file mode 100644 index 0000000..179554f --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt @@ -0,0 +1,35 @@ +package onku.backend.domain.attendance.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.attendance.dto.AttendanceRequest +import onku.backend.domain.attendance.dto.AttendanceResponse +import onku.backend.domain.attendance.dto.AttendanceTokenResponse +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 AttendanceController( + private val attendanceService: AttendanceService +) { + + @PostMapping("/token") + @Operation(summary = "출석용 토큰 발급 [USER]", description = "15초 유효") + fun issueQrToken(@CurrentMember member: Member): ResponseEntity> { + val headers = HttpHeaders().apply { add(HttpHeaders.CACHE_CONTROL, "no-store") } + return ResponseEntity.ok().headers(headers).body(SuccessResponse.ok(attendanceService.issueAttendanceTokenFor(member))) + } + + @PostMapping("/scan") + @Operation(summary = "출석 스캔 [ADMIN]", description = "열린 세션 자동 선택 → 토큰 검증 & 소비 → insert") + fun scan(@CurrentMember admin: Member, @RequestBody req: AttendanceRequest): SuccessResponse { + return SuccessResponse.ok(attendanceService.scanAndRecordBy(admin, req.token)) + } +} From 087e07108f632d1fab401db9db4e9c2d32d02fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:14:57 +0900 Subject: [PATCH 083/470] =?UTF-8?q?chore:=20TokenGenerator=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9C=84=EC=B9=98=20=EC=9D=B4=EB=8F=99=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/redis/util/TokenGenerator.kt | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 src/main/kotlin/onku/backend/global/redis/util/TokenGenerator.kt diff --git a/src/main/kotlin/onku/backend/global/redis/util/TokenGenerator.kt b/src/main/kotlin/onku/backend/global/redis/util/TokenGenerator.kt deleted file mode 100644 index 9c6df6b..0000000 --- a/src/main/kotlin/onku/backend/global/redis/util/TokenGenerator.kt +++ /dev/null @@ -1,15 +0,0 @@ -package onku.backend.global.redis.util - -import org.springframework.stereotype.Component -import java.security.SecureRandom -import java.util.Base64 - -@Component -class TokenGenerator { - private val random = SecureRandom() - fun generateOpaqueToken(bytes: Int = 32): String { - val buf = ByteArray(bytes) - random.nextBytes(buf) - return Base64.getUrlEncoder().withoutPadding().encodeToString(buf) - } -} From f72fda49f3568e68eb519fe1c1a43eb12a491cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 16:16:06 +0900 Subject: [PATCH 084/470] =?UTF-8?q?refactor:=20redis=20token=20cache=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=93=A4=EC=9D=98=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/attendance/service/AttendanceService.kt | 4 ++-- .../onku/backend/global/auth/service/AuthServiceImpl.kt | 4 ++-- .../AttendanceTokenCache.kt} | 4 ++-- .../{RefreshTokenCacheUtil.kt => cache/RefreshTokenCache.kt} | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/main/kotlin/onku/backend/global/redis/{AttendanceTokenCacheUtil.kt => cache/AttendanceTokenCache.kt} (97%) rename src/main/kotlin/onku/backend/global/redis/{RefreshTokenCacheUtil.kt => cache/RefreshTokenCache.kt} (90%) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 65efee2..7875393 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -10,7 +10,7 @@ import onku.backend.domain.member.repository.MemberProfileRepository import onku.backend.domain.session.repository.SessionRepository import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode -import onku.backend.global.redis.AttendanceTokenCacheUtil +import onku.backend.global.redis.cache.AttendanceTokenCache import onku.backend.global.util.TokenGenerator import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service @@ -20,7 +20,7 @@ import java.time.LocalDateTime @Service class AttendanceService( - private val tokenCache: AttendanceTokenCacheUtil, + private val tokenCache: AttendanceTokenCache, private val sessionRepository: SessionRepository, private val attendanceRepository: AttendanceRepository, private val memberProfileRepository: MemberProfileRepository, diff --git a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt index bef5f01..d69583f 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -10,7 +10,7 @@ import onku.backend.global.auth.AuthErrorCode import onku.backend.global.auth.dto.* import onku.backend.global.auth.jwt.JwtUtil import onku.backend.global.exception.CustomException -import onku.backend.global.redis.RefreshTokenCacheUtil +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 @@ -27,7 +27,7 @@ class AuthServiceImpl( private val kakaoService: KakaoService, private val memberProfileRepository: MemberProfileRepository, private val jwtUtil: JwtUtil, - private val refreshTokenCacheUtil: RefreshTokenCacheUtil, + private val refreshTokenCacheUtil: RefreshTokenCache, @Value("\${jwt.refresh-ttl}") private val refreshTtl: Duration, @Value("\${jwt.onboarding-ttl}") private val onboardingTtl: Duration, ) : AuthService { diff --git a/src/main/kotlin/onku/backend/global/redis/AttendanceTokenCacheUtil.kt b/src/main/kotlin/onku/backend/global/redis/cache/AttendanceTokenCache.kt similarity index 97% rename from src/main/kotlin/onku/backend/global/redis/AttendanceTokenCacheUtil.kt rename to src/main/kotlin/onku/backend/global/redis/cache/AttendanceTokenCache.kt index a3e4e8c..9c8e8a6 100644 --- a/src/main/kotlin/onku/backend/global/redis/AttendanceTokenCacheUtil.kt +++ b/src/main/kotlin/onku/backend/global/redis/cache/AttendanceTokenCache.kt @@ -1,4 +1,4 @@ -package onku.backend.global.redis +package onku.backend.global.redis.cache import onku.backend.global.redis.dto.TokenData import org.springframework.beans.factory.annotation.Qualifier @@ -10,7 +10,7 @@ import java.time.LocalDateTime import java.time.ZoneId @Component -class AttendanceTokenCacheUtil( +class AttendanceTokenCache( private val redis: StringRedisTemplate, @Qualifier("attendanceSwapScript") private val swapScript: DefaultRedisScript, diff --git a/src/main/kotlin/onku/backend/global/redis/RefreshTokenCacheUtil.kt b/src/main/kotlin/onku/backend/global/redis/cache/RefreshTokenCache.kt similarity index 90% rename from src/main/kotlin/onku/backend/global/redis/RefreshTokenCacheUtil.kt rename to src/main/kotlin/onku/backend/global/redis/cache/RefreshTokenCache.kt index cbff4e9..33a2c1c 100644 --- a/src/main/kotlin/onku/backend/global/redis/RefreshTokenCacheUtil.kt +++ b/src/main/kotlin/onku/backend/global/redis/cache/RefreshTokenCache.kt @@ -1,11 +1,11 @@ -package onku.backend.global.redis +package onku.backend.global.redis.cache import org.springframework.data.redis.core.RedisTemplate import org.springframework.stereotype.Component import java.time.Duration @Component -class RefreshTokenCacheUtil( +class RefreshTokenCache( private val redisTemplate: RedisTemplate ) { companion object { From 843f5c026c05ea20bd6571863c8fcf718417b3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 13 Oct 2025 22:34:04 +0900 Subject: [PATCH 085/470] =?UTF-8?q?refactor=20:=20=EC=A7=80=EC=97=B0=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=84=A4=EC=A0=95=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/kupick/Kupick.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt index d444fc3..f61acec 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt @@ -13,12 +13,12 @@ class Kupick( @Column(name = "kupick_id") val id: Long? = null, - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") val member : Member, - @Column(name = "summit_date") - var summitDate: LocalDateTime? = null, + @Column(name = "submit_date") + var submitDate: LocalDateTime? = null, @Column(name = "application_image_url") var applicationImageUrl: String, @@ -46,20 +46,20 @@ class Kupick( member = member, applicationImageUrl = applicationImageUrl, applicationDate = applicationDate, - summitDate = applicationDate + submitDate = applicationDate ) } } - fun summitView(viewImageUrl: String, nowDate: LocalDateTime) { + fun submitView(viewImageUrl: String, nowDate: LocalDateTime) { this.viewImageUrl = viewImageUrl this.viewDate = nowDate - this.summitDate = nowDate + this.submitDate = nowDate } fun updateApplication(newUrl: String, newDate: LocalDateTime) { this.applicationImageUrl = newUrl this.applicationDate = newDate - this.summitDate = newDate + this.submitDate = newDate } } \ No newline at end of file From 7b57ef2b573c0264debd0463bbc0e47614ce8830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 13 Oct 2025 22:34:23 +0900 Subject: [PATCH 086/470] =?UTF-8?q?refactor=20:=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EA=B5=AC=EB=AC=B8=20=EC=82=AD=EC=A0=9C=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/kupick/controller/KupickController.kt | 13 ++++--------- .../backend/domain/kupick/facade/KupickFacade.kt | 8 ++++---- .../backend/domain/kupick/service/KupickService.kt | 6 +++--- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/kupick/controller/KupickController.kt b/src/main/kotlin/onku/backend/domain/kupick/controller/KupickController.kt index 2b47959..5c87800 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/controller/KupickController.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/KupickController.kt @@ -8,11 +8,9 @@ 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.HttpStatus 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.ResponseStatus import org.springframework.web.bind.annotation.RestController @RestController @@ -22,31 +20,28 @@ class KupickController( private val kupickFacade: KupickFacade ) { @GetMapping("/application") - @ResponseStatus(HttpStatus.OK) @Operation( summary = "큐픽 신청 서류 제출", description = "큐픽 신청용 서류 제출 signedUrl 반환" ) - fun summitApplication( + fun submitApplication( @CurrentMember member : Member, fileName : String ) : ResponseEntity> { - return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.summitApplication(member, fileName))) + return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.submitApplication(member, fileName))) } @GetMapping("/view") - @ResponseStatus(HttpStatus.OK) @Operation( summary = "큐픽 시청 서류 제출", description = "큐픽 시청 증빙 서류 제출 signedUrl 반환" ) - fun summitView( + fun submitView( @CurrentMember member: Member, fileName: String ) : ResponseEntity> { - return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.summitView(member, fileName))) + return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.submitView(member, fileName))) } @GetMapping("/my") - @ResponseStatus(HttpStatus.OK) @Operation( summary = "큐픽 조회", description = "내가 신청한 큐픽 내역 조회" diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index 3df76ff..86115a6 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -13,17 +13,17 @@ class KupickFacade( private val s3Service: S3Service, private val kupickService: KupickService ) { - fun summitApplication(member: Member, fileName: String): GetPreSignedUrlDto { + fun submitApplication(member: Member, fileName: String): GetPreSignedUrlDto { val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_APPLICATION.name) - kupickService.summitApplication(member, signedUrlDto.key) + kupickService.submitApplication(member, signedUrlDto.key) return GetPreSignedUrlDto( signedUrlDto.preSignedUrl ) } - fun summitView(member: Member, fileName: String): GetPreSignedUrlDto { + fun submitView(member: Member, fileName: String): GetPreSignedUrlDto { val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_VIEW.name) - kupickService.summitView(member, signedUrlDto.key) + kupickService.submitView(member, signedUrlDto.key) return GetPreSignedUrlDto( signedUrlDto.preSignedUrl ) diff --git a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt index 59d1df6..f840711 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -16,7 +16,7 @@ class KupickService( private val kupickRepository: KupickRepository ) { @Transactional - fun summitApplication(member: Member, applicationUrl : String) { + fun submitApplication(member: Member, applicationUrl : String) { val monthObject = TimeRangeUtil.getCurrentMonthRange() val existing = kupickRepository.findFirstByMemberAndApplicationDateBetween( member, monthObject.startOfMonth, monthObject.startOfNextMonth @@ -30,14 +30,14 @@ class KupickService( } @Transactional - fun summitView(member: Member, viewUrl: String) { + fun submitView(member: Member, viewUrl: String) { val monthObject = TimeRangeUtil.getCurrentMonthRange() val now = LocalDateTime.now() val kupick = kupickRepository.findThisMonthByMember( member, monthObject.startOfMonth, monthObject.startOfNextMonth) ?: throw CustomException(ErrorCode.KUPICK_APPLICATION_FIRST) - kupick.summitView(viewUrl, now) + kupick.submitView(viewUrl, now) } @Transactional(readOnly = true) From 66a617ebf3773b7792b557b8b16bb08f6d0c7764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 13 Oct 2025 23:02:15 +0900 Subject: [PATCH 087/470] =?UTF-8?q?feat:=20attendance,=20session=EC=97=90?= =?UTF-8?q?=EC=84=9C=20baseentity=20=EC=83=81=EC=86=8D=EB=B0=9B=EA=B8=B0?= =?UTF-8?q?=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/attendance/Attendance.kt | 3 ++- src/main/kotlin/onku/backend/domain/session/Session.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt b/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt index af22270..2eac757 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt @@ -2,6 +2,7 @@ package onku.backend.domain.attendance import jakarta.persistence.* import onku.backend.domain.attendance.enums.AttendanceStatus +import onku.backend.global.entity.BaseEntity import java.time.LocalDateTime @Entity @@ -29,4 +30,4 @@ class Attendance( @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 32) var status: AttendanceStatus -) +) : BaseEntity() diff --git a/src/main/kotlin/onku/backend/domain/session/Session.kt b/src/main/kotlin/onku/backend/domain/session/Session.kt index f070d93..7bb4e09 100644 --- a/src/main/kotlin/onku/backend/domain/session/Session.kt +++ b/src/main/kotlin/onku/backend/domain/session/Session.kt @@ -2,6 +2,7 @@ package onku.backend.domain.session import jakarta.persistence.* import onku.backend.domain.session.enums.SessionCategory +import onku.backend.global.entity.BaseEntity import java.time.LocalDateTime @Entity @@ -41,4 +42,4 @@ class Session( @Column(name = "late_threshold_time", nullable = false) // 지각 기준 시각 val lateThresholdTime: LocalDateTime -) +) : BaseEntity() From 58e5792c5758bc0f74fbde4dfcea54223b159b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 14 Oct 2025 01:23:59 +0900 Subject: [PATCH 088/470] =?UTF-8?q?build=20:=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EA=B4=80=EB=A0=A8=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20#17?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index d182c9a..da9ca6a 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" @@ -45,6 +46,12 @@ dependencies { implementation("software.amazon.awssdk:s3:2.25.34") implementation("software.amazon.awssdk:auth:2.25.34") implementation("software.amazon.awssdk:regions:2.25.34") + //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") } kotlin { From 7fce8a88bf68a9eae51d331affcd814e620928c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 14 Oct 2025 01:24:32 +0900 Subject: [PATCH 089/470] =?UTF-8?q?feat=20:=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=EC=97=90=20fcm=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1=20#17?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6c3910d..01a68ab 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,14 +46,17 @@ jobs: 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 @@ -126,14 +129,17 @@ jobs: 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 From cb31bf2f8907bc432d6f39c48d20f4d31dc361bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 14 Oct 2025 01:25:01 +0900 Subject: [PATCH 090/470] =?UTF-8?q?feat=20:=20fcm=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=9E=91=EC=84=B1=20#17?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/alarm/FCMMessage.kt | 31 +++++++ .../onku/backend/global/alarm/FCMService.kt | 86 +++++++++++++++++++ .../backend/global/alarm/FCMTestController.kt | 29 +++++++ .../global/auth/config/SecurityConfig.kt | 1 + .../backend/global/exception/ErrorCode.kt | 3 +- 5 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/onku/backend/global/alarm/FCMMessage.kt create mode 100644 src/main/kotlin/onku/backend/global/alarm/FCMService.kt create mode 100644 src/main/kotlin/onku/backend/global/alarm/FCMTestController.kt 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..3b38dc3 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/alarm/FCMService.kt @@ -0,0 +1,86 @@ +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.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, + @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(targetToken: String, title: String, body: String, link: String?) { + val message = makeMessage(targetToken, 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() + + 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/FCMTestController.kt b/src/main/kotlin/onku/backend/global/alarm/FCMTestController.kt new file mode 100644 index 0000000..dde2ac5 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/alarm/FCMTestController.kt @@ -0,0 +1,29 @@ +package onku.backend.global.alarm + +import io.swagger.v3.oas.annotations.Hidden +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.global.response.SuccessResponse +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@Hidden +@RestController +@RequestMapping("/test/push") +@Tag(name = "푸시 알림 테스트용 API") +class FCMTestController( + private val fcmService: FCMService +) { + @ResponseStatus(HttpStatus.OK) + @GetMapping("") + @Operation(summary = "푸시 알림 테스트", description = "현재 프론트랑 연동이 안되어서 로그만 확인가능") + fun pushTest( + token : String + ): SuccessResponse { + fcmService.sendMessageTo(token, "알림 제목", "알림 내용", "알림 눌렀을 때 연결되는 링크") + return SuccessResponse.ok(true) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index 06fa940..4ea43bc 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -22,6 +22,7 @@ class SecurityConfig( "/v3/api-docs/**", "/health", "/actuator/health", + "/test/push/**" ) private val ALLOWED_POST = arrayOf( "/api/v1/auth/kakao", diff --git a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt index c5133bb..1d92470 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -15,5 +15,6 @@ enum class ErrorCode( PARAMETER_VALIDATION_ERROR("COMMON422", "파라미터 검증 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), PARAMETER_GRAMMAR_ERROR("COMMON422", "파라미터 문법 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), INVALID_FILE_EXTENSION("S3001", "올바르지 않은 파일 확장자 입니다.", HttpStatus.BAD_REQUEST), - KUPICK_APPLICATION_FIRST("kupick001", "큐픽 신청부터 진행해주세요", HttpStatus.BAD_REQUEST); + KUPICK_APPLICATION_FIRST("kupick001", "큐픽 신청부터 진행해주세요", HttpStatus.BAD_REQUEST), + FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST); } From 7b7bf6b4e13af3a534475a4bbc40b08ca6b917f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 14:30:51 +0900 Subject: [PATCH 091/470] =?UTF-8?q?chore:=20lua=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=9C=84=EC=B9=98=20resources=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=EB=A1=9C=20=EC=98=AE=EA=B8=B0=EA=B3=A0=20gitignore?= =?UTF-8?q?=EC=97=90=EC=84=9C=20resources=20=ED=8F=B4=EB=8D=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- .../backend/global/redis => resources}/lua/consume_token.lua | 0 .../onku/backend/global/redis => resources}/lua/issue_token.lua | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename src/main/{kotlin/onku/backend/global/redis => resources}/lua/consume_token.lua (100%) rename src/main/{kotlin/onku/backend/global/redis => resources}/lua/issue_token.lua (100%) diff --git a/.gitignore b/.gitignore index 7efa193..e1e8df8 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,4 @@ out/ ### YAML ### application.yml -src/main/resources/* \ No newline at end of file +#src/main/resources/* \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/redis/lua/consume_token.lua b/src/main/resources/lua/consume_token.lua similarity index 100% rename from src/main/kotlin/onku/backend/global/redis/lua/consume_token.lua rename to src/main/resources/lua/consume_token.lua diff --git a/src/main/kotlin/onku/backend/global/redis/lua/issue_token.lua b/src/main/resources/lua/issue_token.lua similarity index 100% rename from src/main/kotlin/onku/backend/global/redis/lua/issue_token.lua rename to src/main/resources/lua/issue_token.lua From 5dfb72051a8a85d5646a6d18430fd22276bc2dc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 14:31:35 +0900 Subject: [PATCH 092/470] =?UTF-8?q?fix:=20UTC=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20systemDefaultZone=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/config/TimeConfig.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/config/TimeConfig.kt b/src/main/kotlin/onku/backend/global/config/TimeConfig.kt index 63c9fda..91fc12d 100644 --- a/src/main/kotlin/onku/backend/global/config/TimeConfig.kt +++ b/src/main/kotlin/onku/backend/global/config/TimeConfig.kt @@ -8,5 +8,5 @@ import java.time.Clock @Configuration class TimeConfig { @Bean - fun systemClock(): Clock = Clock.systemUTC() + fun systemClock(): Clock = Clock.systemDefaultZone() } \ No newline at end of file From 5c0e26cfad984c0d8d9db7bedb947b235e5bbe86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 14:32:04 +0900 Subject: [PATCH 093/470] =?UTF-8?q?chore:=20lua=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/config/RedisLuaConfig.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/config/RedisLuaConfig.kt b/src/main/kotlin/onku/backend/global/config/RedisLuaConfig.kt index 7ccdf70..4d6a45b 100644 --- a/src/main/kotlin/onku/backend/global/config/RedisLuaConfig.kt +++ b/src/main/kotlin/onku/backend/global/config/RedisLuaConfig.kt @@ -11,14 +11,14 @@ class RedisLuaConfig { @Bean fun attendanceSwapScript(): DefaultRedisScript = DefaultRedisScript().apply { - setLocation(ClassPathResource("onku/backend/global/redis/lua/issue_token.lua")) + setLocation(ClassPathResource("lua/issue_token.lua")) setResultType(String::class.java) } @Bean fun attendanceConsumeScript(): DefaultRedisScript = DefaultRedisScript().apply { - setLocation(ClassPathResource("onku/backend/global/redis/lua/consume_token.lua")) + setLocation(ClassPathResource("lua/consume_token.lua")) setResultType(String::class.java) } } From 3d1b51562bf702806c3a6ef122334c23541173e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 14:33:27 +0900 Subject: [PATCH 094/470] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=95=AD=EB=AA=A9=EC=9D=B8=20?= =?UTF-8?q?scope=EB=A5=BC=20=EA=B2=80=EC=82=AC=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/auth/jwt/JwtFilter.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/jwt/JwtFilter.kt b/src/main/kotlin/onku/backend/global/auth/jwt/JwtFilter.kt index 7dd2fbe..90c89b1 100644 --- a/src/main/kotlin/onku/backend/global/auth/jwt/JwtFilter.kt +++ b/src/main/kotlin/onku/backend/global/auth/jwt/JwtFilter.kt @@ -29,11 +29,9 @@ class JwtFilter( val email = jwtUtil.getEmail(token) val roles = jwtUtil.getRoles(token) - val scopes = jwtUtil.getScopes(token) val authorities = buildList { roles.forEach { add(SimpleGrantedAuthority("ROLE_${it.uppercase()}")) } - scopes.forEach { add(SimpleGrantedAuthority(it.uppercase())) } } val authentication = UsernamePasswordAuthenticationToken(email, null, authorities) From 7f17ea4853594c17db4f9f000803ebdf74428946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 14:34:18 +0900 Subject: [PATCH 095/470] =?UTF-8?q?feat:=20Role=EC=9D=98=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=EC=99=80=20=EA=B0=81=20Role=EC=9D=B4=20=EA=B0=80?= =?UTF-8?q?=EC=A7=80=EB=8A=94=20=EA=B6=8C=ED=95=9C=EC=9D=84=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EC=9D=98=20enum=EC=97=90=EC=84=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/member/enums/Role.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/member/enums/Role.kt b/src/main/kotlin/onku/backend/domain/member/enums/Role.kt index 6c12c58..3deb000 100644 --- a/src/main/kotlin/onku/backend/domain/member/enums/Role.kt +++ b/src/main/kotlin/onku/backend/domain/member/enums/Role.kt @@ -1,3 +1,12 @@ package onku.backend.domain.member.enums -enum class Role { USER, ADMIN } \ No newline at end of file +enum class Role { + GUEST, USER, ADMIN, MANAGEMENT; + + fun authorities(): List = when (this) { + GUEST -> listOf("GUEST") + USER -> listOf("USER") + ADMIN -> listOf("ADMIN","USER") + MANAGEMENT -> listOf("MANAGEMENT","ADMIN","USER") + } +} \ No newline at end of file From c0f9456c94016efc0536969a09d20035555e8d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 14:35:12 +0900 Subject: [PATCH 096/470] =?UTF-8?q?fix:=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EB=8B=A8=EC=97=90=EC=84=9C=20Role=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=ED=95=98=EB=8D=98=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/service/AttendanceService.kt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 7875393..87aec1c 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -31,10 +31,6 @@ class AttendanceService( @Transactional(readOnly = true) fun issueAttendanceTokenFor(member: Member): AttendanceTokenResponse { - if (member.role != Role.USER && member.role != Role.ADMIN) { - throw CustomException(ErrorCode.FORBIDDEN) - } - val now = LocalDateTime.now(clock) val expAt = now.plusSeconds(ttlSeconds) val token = tokenGenerator.generateOpaqueToken() @@ -45,10 +41,6 @@ class AttendanceService( @Transactional fun scanAndRecordBy(admin: Member, token: String): AttendanceResponse { - if (admin.role != Role.ADMIN) { - throw CustomException(ErrorCode.FORBIDDEN) - } - val now = LocalDateTime.now(clock) val session = sessionRepository.findOpenSession(now) From 0bdffea08dc84b85fb40898fefefd61b03c4e2f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 14:36:09 +0900 Subject: [PATCH 097/470] =?UTF-8?q?feat:=20approve()=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EC=97=90=EC=84=9C=20ApprovalStatus=EC=99=80=20Role=EC=9D=84=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=84=A4=EC=A0=95=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=B6=94=EA=B0=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/member/Member.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/Member.kt b/src/main/kotlin/onku/backend/domain/member/Member.kt index 4367482..ffa29e5 100644 --- a/src/main/kotlin/onku/backend/domain/member/Member.kt +++ b/src/main/kotlin/onku/backend/domain/member/Member.kt @@ -17,7 +17,7 @@ class Member( @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) - val role: Role = Role.USER, + var role: Role = Role.USER, @Enumerated(EnumType.STRING) @Column(name = "social_type", nullable = false, length = 20) @@ -33,7 +33,10 @@ class Member( @Column(name = "approval", nullable = false, length = 20) var approval: ApprovalStatus = ApprovalStatus.PENDING ) : BaseEntity() { - fun approve() { this.approval = ApprovalStatus.APPROVED } + 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?) { From 8f2cc684286e9d468c92a76455aba1b8c731607e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 14:37:14 +0900 Subject: [PATCH 098/470] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=EC=97=90=EB=8A=94=20=ED=95=B4=EB=8B=B9=20?= =?UTF-8?q?Role=EC=9D=98=20=EB=AA=A8=EB=93=A0=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=EC=9D=84=20=EC=A3=BC=EC=9E=85=ED=95=98=EB=8F=84=EB=A1=9D=20.au?= =?UTF-8?q?thorities()=20=EB=A1=9C=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt b/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt index d772d62..9345fad 100644 --- a/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt +++ b/src/main/kotlin/onku/backend/global/auth/jwt/JwtUtil.kt @@ -5,6 +5,7 @@ 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 @@ -30,20 +31,19 @@ class JwtUtil( 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 getScopes(token: String): List = parseClaims(token).get("scopes", 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 = listOf("USER")): String = + fun createAccessToken(email: String, roles: List = Role.USER.authorities()): String = createJwt(email, roles, scopes = emptyList(), expiredMs = accessTtl.toMillis()) - fun createRefreshToken(email: String, roles: List = listOf("USER")): String = + 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 = listOf("GUEST"), scopes = listOf("ONBOARDING_ONLY"), expiredMs = Duration.ofMinutes(minutes).toMillis()) + 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() From 376c7bff72bea4c9586d1a25e3fc0db1c56cb946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 14:37:56 +0900 Subject: [PATCH 099/470] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=EC=97=90=EB=8A=94=20=ED=95=B4=EB=8B=B9=20?= =?UTF-8?q?Role=EC=9D=98=20=EB=AA=A8=EB=93=A0=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=EC=9D=84=20=EC=A3=BC=EC=9E=85=ED=95=98=EB=8F=84=EB=A1=9D=20.au?= =?UTF-8?q?thorities()=20=EB=A1=9C=20=EC=A0=84=EB=8B=AC=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/auth/service/AuthServiceImpl.kt | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt index d69583f..04f82a8 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -25,19 +25,12 @@ import java.time.Duration class AuthServiceImpl( private val memberService: MemberService, private val kakaoService: KakaoService, - private val memberProfileRepository: MemberProfileRepository, private val jwtUtil: JwtUtil, private val refreshTokenCacheUtil: RefreshTokenCache, @Value("\${jwt.refresh-ttl}") private val refreshTtl: Duration, @Value("\${jwt.onboarding-ttl}") private val onboardingTtl: Duration, ) : AuthService { - private fun rolesFor(member: Member): List = - when (member.role) { - Role.ADMIN -> listOf("ADMIN", "USER") - Role.USER -> listOf("USER") - } - @Transactional override fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity> { val token = kakaoService.getAccessToken(dto.code) @@ -55,7 +48,7 @@ class AuthServiceImpl( return when (member.approval) { ApprovalStatus.APPROVED -> { - val roles = rolesFor(member) + val roles = member.role.authorities() val access = jwtUtil.createAccessToken(email, roles) val refresh = jwtUtil.createRefreshToken(email, roles) refreshTokenCacheUtil.saveRefreshToken(email, refresh, refreshTtl) @@ -120,7 +113,7 @@ class AuthServiceImpl( throw CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN) } - val roles = rolesFor(member) + val roles = member.role.authorities() val newAccess = jwtUtil.createAccessToken(email, roles) val headers = HttpHeaders().apply { @@ -132,4 +125,4 @@ class AuthServiceImpl( .headers(headers) .body(SuccessResponse.ok("Access Token이 재발급되었습니다.")) } -} +} \ No newline at end of file From 34d1d45b9f3e68ff7675685b15be7f2af4c3fb87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 14:39:26 +0900 Subject: [PATCH 100/470] =?UTF-8?q?refactor:=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=EB=A5=BC=20=EC=83=81=EC=88=98(=EC=B6=94?= =?UTF-8?q?=ED=9B=84=20=EB=B0=B0=EC=97=B4=EB=A1=9C=20=ED=99=95=EC=9E=A5)?= =?UTF-8?q?=EB=A1=9C=20=EA=B4=80=EB=A6=AC=ED=95=98=EA=B3=A0,=20hasRole?= =?UTF-8?q?=EC=97=90=20String=20=EB=8C=80=EC=8B=A0=20Enum.name=EC=9D=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=B4=EC=84=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/auth/config/SecurityConfig.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index 06fa940..9ba1d46 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -1,5 +1,6 @@ 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 @@ -27,7 +28,10 @@ class SecurityConfig( "/api/v1/auth/kakao", "/api/v1/auth/reissue", ) - private const val ONBOARDING_ENDPOINT = "/api/v1/members/onboarding" + // TODO: 엔드포인트가 늘어나면 arrayOf()로 수정 + private const val ONBOARDING_ENDPOINT = "/api/v1/members/onboarding/**" // 온보딩 + private const val ADMIN_ENDPOINT = "/api/v1/auth/admin/**" // 운영진 전체 + private const val MANAGEMENT_ENDPOINT = "/api/v1/attendance/scan/**" // 경총 } @Bean @@ -45,12 +49,13 @@ class SecurityConfig( .requestMatchers(*ALLOWED_POST).permitAll() // 권한 별 엔드포인트 - .requestMatchers(ONBOARDING_ENDPOINT).hasAuthority("ONBOARDING_ONLY") - .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") - .anyRequest().hasRole("USER") + .requestMatchers(ONBOARDING_ENDPOINT).hasRole(Role.GUEST.name) + .requestMatchers(ADMIN_ENDPOINT).hasRole(Role.ADMIN.name) + .requestMatchers(MANAGEMENT_ENDPOINT).hasRole(Role.MANAGEMENT.name) + .anyRequest().hasRole(Role.USER.name) } .addFilterBefore(JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter::class.java) return http.build() } -} +} \ No newline at end of file From 5a638f8e964fd241df9899b83cea7e59721bc178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 16:05:02 +0900 Subject: [PATCH 101/470] =?UTF-8?q?feat:=20base=20entity=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=88=98=EC=A0=95=20=EB=B0=98=EC=98=81=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/repository/AttendanceRepository.kt | 10 +++++++--- .../domain/attendance/service/AttendanceService.kt | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt index ed7ee54..602b597 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt @@ -14,8 +14,10 @@ interface AttendanceRepository : CrudRepository { @Modifying @Query( value = """ - INSERT INTO attendance (session_id, member_id, status, attendance_time) - VALUES (:sessionId, :memberId, :status, :attendanceTime) + INSERT INTO attendance + (session_id, member_id, status, attendance_time, created_at, updated_at) + VALUES + (:sessionId, :memberId, :status, :attendanceTime, :createdAt, :updatedAt) """, nativeQuery = true ) @@ -23,6 +25,8 @@ interface AttendanceRepository : CrudRepository { @Param("sessionId") sessionId: Long, @Param("memberId") memberId: Long, @Param("status") status: String, - @Param("attendanceTime") attendanceTime: LocalDateTime + @Param("attendanceTime") attendanceTime: LocalDateTime, + @Param("createdAt") createdAt: LocalDateTime, + @Param("updatedAt") updatedAt: LocalDateTime ): Int } diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 87aec1c..85ed8f9 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -69,7 +69,9 @@ class AttendanceService( sessionId = session.id!!, memberId = memberId, status = state.name, - attendanceTime = now + attendanceTime = now, + createdAt = now, + updatedAt = now ) } catch (e: DataIntegrityViolationException) { throw CustomException(AttendanceErrorCode.ATTENDANCE_ALREADY_RECORDED) From 84bec8b7d8e46df5040a8eb5858e22c6ba5bc4a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 18:47:14 +0900 Subject: [PATCH 102/470] =?UTF-8?q?feat:=20profile=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=EC=9A=A9=20DTO=20=EC=B6=94=EA=B0=80=20#21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/member/dto/MemberProfileResponse.kt | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt 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..05bd452 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.member.dto + +import onku.backend.domain.member.enums.Part + +data class MemberProfileResponse( + val name: String?, + val part: Part +) From b18a82a732a2437b8b95a8617edc5c6207e0d126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 18:47:47 +0900 Subject: [PATCH 103/470] =?UTF-8?q?feat:=20profile=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20#21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/MemberProfileService.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index e79fe36..294a6da 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -3,6 +3,7 @@ package onku.backend.domain.member.service import onku.backend.domain.member.Member import onku.backend.domain.member.MemberProfile import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.dto.MemberProfileResponse import onku.backend.domain.member.dto.OnboardingRequest import onku.backend.domain.member.dto.OnboardingResponse import onku.backend.domain.member.enums.ApprovalStatus @@ -61,4 +62,13 @@ class MemberProfileService( ) } } + @Transactional(readOnly = true) + fun getProfileSummary(member: Member): MemberProfileResponse { + val profile = memberProfileRepository.findById(member.id!!) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + return MemberProfileResponse( + name = profile.name, + part = profile.part + ) + } } From f7b748f03e5815fb15fdce6f5f5faf4af2247f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 18:48:49 +0900 Subject: [PATCH 104/470] =?UTF-8?q?feat:=20profile=20name,=20part=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20#21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/controller/MemberController.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt index f2eb791..31e58c1 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt @@ -4,6 +4,7 @@ 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.MemberProfileResponse import onku.backend.domain.member.dto.OnboardingRequest import onku.backend.domain.member.dto.OnboardingResponse import onku.backend.domain.member.service.MemberProfileService @@ -33,4 +34,15 @@ class MemberController( return SuccessResponse.ok(body) } + @GetMapping("/profile/summary") + @Operation( + summary = "내 프로필 요약 조회", + description = "[TEMP] 현재 로그인한 회원의 이름(name)과 파트(part)만 반환합니다." + ) + fun getMyProfileSummary( // TODO: DisciplinaryRecord 테이블 생성 후 상벌점도 반환 + @CurrentMember member: Member + ): SuccessResponse { + val body = memberProfileService.getProfileSummary(member) + return SuccessResponse.ok(body) + } } \ No newline at end of file From 7a03487f1008c4bed8913d944e32dcdd8516b434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 18:54:03 +0900 Subject: [PATCH 105/470] =?UTF-8?q?chore:=20gitignore=EC=97=90=20fcm=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e1e8df8..23f4035 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ out/ ### YAML ### application.yml -#src/main/resources/* \ No newline at end of file +#src/main/resources/* +src/main/resources/firebase/* \ No newline at end of file From bf26d1116bf51449cd137e927d7717646b8b743e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 19:43:27 +0900 Subject: [PATCH 106/470] =?UTF-8?q?feat:=20redis=EC=97=90=EC=84=9C=20refre?= =?UTF-8?q?sh=20token=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/auth/service/AuthService.kt | 1 + .../backend/global/auth/service/AuthServiceImpl.kt | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt index ced79cb..858efb0 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt @@ -8,5 +8,6 @@ import org.springframework.http.ResponseEntity interface AuthService { fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity> fun reissueAccessToken(refreshToken: String): ResponseEntity> + fun logout(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 index 04f82a8..eb78aa4 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -125,4 +125,15 @@ class AuthServiceImpl( .headers(headers) .body(SuccessResponse.ok("Access Token이 재발급되었습니다.")) } + + @Transactional + override fun logout(refreshToken: String): ResponseEntity> { + val email = runCatching { jwtUtil.getEmail(refreshToken) }.getOrNull() + if (email != null) { + refreshTokenCacheUtil.deleteRefreshToken(email) + } + return ResponseEntity + .status(HttpStatus.OK) + .body(SuccessResponse.ok("로그아웃 되었습니다.")) + } } \ No newline at end of file From 1f1b57bc197832cb70fcaebc09079d48c042cc0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 19:43:45 +0900 Subject: [PATCH 107/470] =?UTF-8?q?feat:=20logout=20endpoint=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/auth/controller/AuthController.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt index 20779c3..e4783fe 100644 --- a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt +++ b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt @@ -24,4 +24,12 @@ class AuthController( @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) } \ No newline at end of file From 5876f0d1dbc871c8cd545e92dbc7492b2e13588b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 20:36:28 +0900 Subject: [PATCH 108/470] =?UTF-8?q?fix:=20socialId=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20String=20=E2=86=92=20Long=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20#20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/member/Member.kt | 2 +- .../onku/backend/domain/member/repository/MemberRepository.kt | 2 +- .../kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/Member.kt b/src/main/kotlin/onku/backend/domain/member/Member.kt index ffa29e5..ff7c809 100644 --- a/src/main/kotlin/onku/backend/domain/member/Member.kt +++ b/src/main/kotlin/onku/backend/domain/member/Member.kt @@ -24,7 +24,7 @@ class Member( val socialType: SocialType, @Column(name = "social_id", nullable = false, length = 100) - val socialId: String, + val socialId: Long, @Column(name = "has_info", nullable = false) var hasInfo: Boolean = false, diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt index c4ec91a..79f18a0 100644 --- a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt @@ -6,5 +6,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface MemberRepository : JpaRepository { fun findByEmail(email: String): Member? - fun findBySocialIdAndSocialType(socialId: String, socialType: SocialType): Member? + fun findBySocialIdAndSocialType(socialId: Long, socialType: SocialType): Member? } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt index eb78aa4..9ac95cf 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -36,7 +36,7 @@ class AuthServiceImpl( val token = kakaoService.getAccessToken(dto.code) val profile = kakaoService.getProfile(token.accessToken) - val socialId = profile.id.toString() + val socialId = profile.id val email = profile.kakaoAccount?.email ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) From d992c4ca78a6e6cc51a8a82db365f99cfb508804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 20:37:12 +0900 Subject: [PATCH 109/470] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20u?= =?UTF-8?q?nlink=20=EC=9A=94=EC=B2=AD=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/service/KakaoService.kt | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/auth/service/KakaoService.kt b/src/main/kotlin/onku/backend/global/auth/service/KakaoService.kt index 51f1b03..200adee 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/KakaoService.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/KakaoService.kt @@ -14,7 +14,8 @@ import org.springframework.web.client.RestClient @Service class KakaoService( @Value("\${oauth.kakao.client-id}") private val clientId: String, - @Value("\${oauth.kakao.redirect-uri}") private val redirectUri: String + @Value("\${oauth.kakao.redirect-uri}") private val redirectUri: String, + @Value("\${oauth.kakao.admin-key}") private val adminKey: String ) { private val client = RestClient.create() @@ -53,4 +54,23 @@ class KakaoService( throw CustomException(AuthErrorCode.KAKAO_API_COMMUNICATION_ERROR) } } + + fun adminUnlink(userId: Long) { + 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) + } + } } From 94b2660cafc816da6368b6532d781ed91676f7c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 20:37:40 +0900 Subject: [PATCH 110/470] =?UTF-8?q?feat:=20Member=EC=99=80=20MemberProfile?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20#20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/MemberService.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt index 1120c50..c5764d2 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -5,6 +5,7 @@ import onku.backend.domain.member.MemberErrorCode 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.exception.CustomException import org.springframework.stereotype.Service @@ -14,20 +15,21 @@ import org.springframework.transaction.annotation.Transactional @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) - fun getBySocialIdOrNull(socialId: String, socialType: SocialType): Member? = + fun getBySocialIdOrNull(socialId: Long, socialType: SocialType): Member? = memberRepository.findBySocialIdAndSocialType(socialId, socialType) - fun getBySocialId(socialId: String, socialType: SocialType): Member = + fun getBySocialId(socialId: Long, socialType: SocialType): Member = getBySocialIdOrNull(socialId, socialType) ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) @Transactional - fun upsertSocialMember(email: String?, socialId: String, type: SocialType): Member { + fun upsertSocialMember(email: String?, socialId: Long, type: SocialType): Member { val existing = memberRepository.findBySocialIdAndSocialType(socialId, type) if (existing != null) { if (!email.isNullOrBlank() && existing.email != email) { @@ -57,4 +59,15 @@ class MemberService( 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) + } } From 0d164c16f9d459a0541d9b622e0cb7cfc4e2d293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 20:38:16 +0900 Subject: [PATCH 111/470] =?UTF-8?q?feat:=20=ED=95=B4=EB=8B=B9=20Member?= =?UTF-8?q?=EC=9D=98=20Profile=20=EC=A1=B4=EC=9E=AC=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/repository/MemberProfileRepository.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt index 751ba26..27975d8 100644 --- a/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt @@ -1,6 +1,15 @@ package onku.backend.domain.member.repository + import onku.backend.domain.member.MemberProfile 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 MemberProfileRepository : JpaRepository +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 +} From 6a794d698b263caead63efcd48c6a019e699ee20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 14 Oct 2025 20:39:16 +0900 Subject: [PATCH 112/470] =?UTF-8?q?feat:=20kakao=20unlink=20+=20redis=20RT?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20+=20Member=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=E2=86=92=20=ED=83=88=ED=87=B4=20controller=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.kt | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt index e4783fe..6d415b1 100644 --- a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt +++ b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt @@ -2,9 +2,13 @@ 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.domain.member.service.MemberService +import onku.backend.global.annotation.CurrentMember 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.auth.service.KakaoService import onku.backend.global.response.SuccessResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -13,7 +17,9 @@ import org.springframework.web.bind.annotation.* @RequestMapping("/api/v1/auth") @Tag(name = "인증 API", description = "소셜 로그인 및 토큰 재발급") class AuthController( - private val authService: AuthService + private val authService: AuthService, + private val kakaoService: KakaoService, + private val memberService: MemberService, ) { @PostMapping("/kakao") @Operation(summary = "카카오 로그인", description = "인가코드를 body로 받아 사용자를 식별합니다.") @@ -32,4 +38,22 @@ class AuthController( ) 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> { + val kakaoUserId = member.socialId + kakaoService.adminUnlink(kakaoUserId) + authService.logout(refreshToken) + val memberId = member.id ?: throw IllegalStateException("회원 ID가 없습니다.") + memberService.deleteMemberById(memberId) + + return ResponseEntity.ok(SuccessResponse.ok("회원 탈퇴가 완료되었습니다.")) + } } \ No newline at end of file From 8049475b2c6b5cce6f4cc820856b4139780e5d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 14 Oct 2025 20:39:34 +0900 Subject: [PATCH 113/470] =?UTF-8?q?chore=20:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EA=B6=8C=ED=95=9C=20=EB=B6=80=EC=97=AC=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20#18?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/admin/controller/AdminController.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt b/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt index a62cd8a..848e28b 100644 --- a/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt +++ b/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt @@ -8,7 +8,6 @@ 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.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* @Tag(name = "관리자 API", description = "관리자 권한용 API") @@ -23,7 +22,6 @@ class AdminController( description = "PENDING 상태의 회원만 승인/거절할 수 있습니다. (PENDING → APPROVED/REJECTED)" ) @PatchMapping("/{memberId}/approval") - @PreAuthorize("hasRole('ADMIN')") fun updateApproval( @PathVariable memberId: Long, @RequestBody @Valid body: UpdateApprovalRequest From a8067a12cd40d20cd139fa2883c2276e158d303c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 14 Oct 2025 20:40:47 +0900 Subject: [PATCH 114/470] =?UTF-8?q?chore=20:=20=EB=85=84=EC=9B=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=9E=90=EB=A1=9C=20=EB=B0=9B=EC=95=84=EC=84=9C=20?= =?UTF-8?q?=ED=95=B4=EB=8B=B9=20=EC=9B=94=EA=B3=BC=20=EB=8B=A4=EC=9D=8C=20?= =?UTF-8?q?=EC=9B=94=EC=9D=84=20=EA=B5=AC=ED=95=98=EB=8A=94=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=ED=95=A8=EC=88=98=20=EC=9E=91=EC=84=B1=20#18?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt b/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt index 5973e27..a30bb99 100644 --- a/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt +++ b/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt @@ -1,6 +1,7 @@ package onku.backend.global.time import java.time.LocalDateTime +import java.time.YearMonth import java.time.ZoneId object TimeRangeUtil { @@ -15,4 +16,11 @@ object TimeRangeUtil { val startOfNextMonth = startOfMonth.plusMonths(1) return MonthRange(startOfMonth, startOfNextMonth) } + + fun monthRange(year: Int, month: Int, zoneId: ZoneId = ZoneId.of("Asia/Seoul")): MonthRange { + val ym = YearMonth.of(year, month) + val startZ = ym.atDay(1).atStartOfDay(zoneId) + val nextStartZ = startZ.plusMonths(1) + return MonthRange(startZ.toLocalDateTime(), nextStartZ.toLocalDateTime()) + } } \ No newline at end of file From 3658ec447b539c0d4c3b2e36efd2bfae8907fbbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 14 Oct 2025 20:41:14 +0900 Subject: [PATCH 115/470] =?UTF-8?q?chore=20:=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95(=ED=81=90=ED=94=BD=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=EA=B2=BD=EC=B4=9D=EB=A7=8C=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C)=20#18?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/auth/config/SecurityConfig.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index a1561e0..74a7810 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -32,7 +32,9 @@ class SecurityConfig( // TODO: 엔드포인트가 늘어나면 arrayOf()로 수정 private const val ONBOARDING_ENDPOINT = "/api/v1/members/onboarding/**" // 온보딩 private const val ADMIN_ENDPOINT = "/api/v1/auth/admin/**" // 운영진 전체 - private const val MANAGEMENT_ENDPOINT = "/api/v1/attendance/scan/**" // 경총 + private val MANAGEMENT_ENDPOINT = arrayOf( + "/api/v1/attendance/scan/**", + "/api/v1/kupick/manage/**") // 경총 } @Bean @@ -52,7 +54,7 @@ class SecurityConfig( // 권한 별 엔드포인트 .requestMatchers(ONBOARDING_ENDPOINT).hasRole(Role.GUEST.name) .requestMatchers(ADMIN_ENDPOINT).hasRole(Role.ADMIN.name) - .requestMatchers(MANAGEMENT_ENDPOINT).hasRole(Role.MANAGEMENT.name) + .requestMatchers(*MANAGEMENT_ENDPOINT).hasRole(Role.MANAGEMENT.name) .anyRequest().hasRole(Role.USER.name) } .addFilterBefore(JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter::class.java) From 6ab350e12e359a87834a2cc166fb450218d7f09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 14 Oct 2025 20:42:12 +0900 Subject: [PATCH 116/470] =?UTF-8?q?feat=20:=20=ED=81=90=ED=94=BD=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=A1=B0=ED=9A=8C=20API=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20#18?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/KupickManagerController.kt | 30 ++++++++++++++++ .../controller/{ => user}/KupickController.kt | 2 +- .../kupick/dto/ShowUpdateResponseDto.kt | 36 +++++++++++++++++++ .../domain/kupick/facade/KupickFacade.kt | 30 +++++++++++++++- .../kupick/repository/KupickRepository.kt | 13 +++++++ .../projection/KupickWithProfile.kt | 9 +++++ .../domain/kupick/service/KupickService.kt | 12 ++++++- 7 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt rename src/main/kotlin/onku/backend/domain/kupick/controller/{ => user}/KupickController.kt (97%) create mode 100644 src/main/kotlin/onku/backend/domain/kupick/dto/ShowUpdateResponseDto.kt create mode 100644 src/main/kotlin/onku/backend/domain/kupick/repository/projection/KupickWithProfile.kt 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..0c73ed6 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt @@ -0,0 +1,30 @@ +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.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.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/kupick/manage") +@Tag(name = "큐픽 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))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/kupick/controller/KupickController.kt b/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt similarity index 97% rename from src/main/kotlin/onku/backend/domain/kupick/controller/KupickController.kt rename to src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt index 5c87800..6a68877 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/controller/KupickController.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.kupick.controller +package onku.backend.domain.kupick.controller.user import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag diff --git a/src/main/kotlin/onku/backend/domain/kupick/dto/ShowUpdateResponseDto.kt b/src/main/kotlin/onku/backend/domain/kupick/dto/ShowUpdateResponseDto.kt new file mode 100644 index 0000000..c25a2a4 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/dto/ShowUpdateResponseDto.kt @@ -0,0 +1,36 @@ +package onku.backend.domain.kupick.dto + +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 + ) { + companion object { + fun of(memberProfile : MemberProfile, kupick : Kupick) = ShowUpdateResponseDto ( + memberProfile.name, + memberProfile.part, + kupick.id, + kupick.submitDate, + kupick.applicationImageUrl, + kupick.viewImageUrl, + kupick.approval + ) + } +} diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index 86115a6..c9110c8 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -1,5 +1,6 @@ package onku.backend.domain.kupick.facade +import onku.backend.domain.kupick.dto.ShowUpdateResponseDto import onku.backend.domain.kupick.dto.ViewMyKupickResponseDto import onku.backend.domain.kupick.service.KupickService import onku.backend.domain.member.Member @@ -11,7 +12,7 @@ import org.springframework.stereotype.Component @Component class KupickFacade( private val s3Service: S3Service, - private val kupickService: KupickService + private val kupickService: KupickService, ) { fun submitApplication(member: Member, fileName: String): GetPreSignedUrlDto { val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_APPLICATION.name) @@ -42,4 +43,31 @@ class KupickFacade( urls?.getViewDate() ) } + + fun showUpdate(year : Int, month : Int): List { + val profiles = kupickService.findAllAsShowUpdateResponse(year, month) + return profiles.map { p -> + val memberId = p.memberProfile.memberId!! // presign에 사용 + val profile = p.memberProfile // MemberProfile + + val applicationUrl: String? = + p.kupick.applicationImageUrl + .takeIf { it.isNotBlank() } + ?.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 + ) + } + } } \ 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 index 025a0c4..0418e6d 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt @@ -2,6 +2,7 @@ package onku.backend.domain.kupick.repository import onku.backend.domain.kupick.Kupick 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 @@ -42,4 +43,16 @@ interface KupickRepository : JpaRepository { @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 } \ 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 index f840711..0d2de51 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -3,6 +3,7 @@ package onku.backend.domain.kupick.service import onku.backend.domain.kupick.Kupick 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.exception.ErrorCode @@ -13,7 +14,7 @@ import java.time.LocalDateTime @Service class KupickService( - private val kupickRepository: KupickRepository + private val kupickRepository: KupickRepository, ) { @Transactional fun submitApplication(member: Member, applicationUrl : String) { @@ -49,4 +50,13 @@ class KupickService( 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 + ) + } } \ No newline at end of file From d1a22e6093f91f1fffba3a842039beca41306548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 14 Oct 2025 20:56:08 +0900 Subject: [PATCH 117/470] =?UTF-8?q?feat=20:=20=ED=81=90=ED=94=BD=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8/=EB=AF=B8=EC=8A=B9=EC=9D=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20API=20=EC=9E=91=EC=84=B1=20#18?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/kupick/Kupick.kt | 4 ++++ .../controller/manager/KupickManagerController.kt | 15 ++++++++++++++- .../kupick/controller/user/KupickController.kt | 2 +- .../kupick/dto/request/KupickApprovalRequest.kt | 10 ++++++++++ .../dto/{ => response}/ShowUpdateResponseDto.kt | 2 +- .../dto/{ => response}/ViewMyKupickResponseDto.kt | 2 +- .../backend/domain/kupick/facade/KupickFacade.kt | 10 ++++++++-- .../domain/kupick/service/KupickService.kt | 8 ++++++++ .../onku/backend/global/exception/ErrorCode.kt | 1 + 9 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/kupick/dto/request/KupickApprovalRequest.kt rename src/main/kotlin/onku/backend/domain/kupick/dto/{ => response}/ShowUpdateResponseDto.kt (96%) rename src/main/kotlin/onku/backend/domain/kupick/dto/{ => response}/ViewMyKupickResponseDto.kt (92%) diff --git a/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt index f61acec..4cce914 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt @@ -62,4 +62,8 @@ class Kupick( this.applicationDate = newDate this.submitDate = newDate } + + fun updateApproval(approval: Boolean) { + this.approval = approval + } } \ 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 index 0c73ed6..5181169 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt @@ -2,11 +2,13 @@ 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.ShowUpdateResponseDto +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.GetMapping +import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -27,4 +29,15 @@ class KupickManagerController( ) : 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 index 6a68877..0394713 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt @@ -2,7 +2,7 @@ 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.ViewMyKupickResponseDto +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 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/ShowUpdateResponseDto.kt b/src/main/kotlin/onku/backend/domain/kupick/dto/response/ShowUpdateResponseDto.kt similarity index 96% rename from src/main/kotlin/onku/backend/domain/kupick/dto/ShowUpdateResponseDto.kt rename to src/main/kotlin/onku/backend/domain/kupick/dto/response/ShowUpdateResponseDto.kt index c25a2a4..5066047 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/dto/ShowUpdateResponseDto.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/dto/response/ShowUpdateResponseDto.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.kupick.dto +package onku.backend.domain.kupick.dto.response import io.swagger.v3.oas.annotations.media.Schema import onku.backend.domain.kupick.Kupick diff --git a/src/main/kotlin/onku/backend/domain/kupick/dto/ViewMyKupickResponseDto.kt b/src/main/kotlin/onku/backend/domain/kupick/dto/response/ViewMyKupickResponseDto.kt similarity index 92% rename from src/main/kotlin/onku/backend/domain/kupick/dto/ViewMyKupickResponseDto.kt rename to src/main/kotlin/onku/backend/domain/kupick/dto/response/ViewMyKupickResponseDto.kt index f84a7ff..cc583c9 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/dto/ViewMyKupickResponseDto.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/dto/response/ViewMyKupickResponseDto.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.kupick.dto +package onku.backend.domain.kupick.dto.response import java.time.LocalDateTime diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index c9110c8..f14a741 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -1,7 +1,8 @@ package onku.backend.domain.kupick.facade -import onku.backend.domain.kupick.dto.ShowUpdateResponseDto -import onku.backend.domain.kupick.dto.ViewMyKupickResponseDto +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.s3.dto.GetPreSignedUrlDto @@ -70,4 +71,9 @@ class KupickFacade( ) } } + + fun decideApproval(kupickApprovalRequest: KupickApprovalRequest): Boolean { + kupickService.decideApproval(kupickApprovalRequest.kupickId, kupickApprovalRequest.approval) + return true + } } \ 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 index 0d2de51..2d67bde 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -8,6 +8,7 @@ import onku.backend.domain.member.Member import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode 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 @@ -59,4 +60,11 @@ class KupickService( monthObject.startOfNextMonth ) } + + @Transactional + fun decideApproval(kupickId: Long, approval: Boolean) { + val kupick = kupickRepository.findByIdOrNull(kupickId) + ?: throw CustomException(ErrorCode.KUPICK_NOT_FOUND) + kupick.updateApproval(approval) + } } \ 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 index 1d92470..0963286 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -16,5 +16,6 @@ enum class ErrorCode( PARAMETER_GRAMMAR_ERROR("COMMON422", "파라미터 문법 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), INVALID_FILE_EXTENSION("S3001", "올바르지 않은 파일 확장자 입니다.", HttpStatus.BAD_REQUEST), KUPICK_APPLICATION_FIRST("kupick001", "큐픽 신청부터 진행해주세요", HttpStatus.BAD_REQUEST), + KUPICK_NOT_FOUND("kupick002", "해당 큐픽 객체를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST); } From 1bbe849495bf380495764e26e26ab0828ceb3d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 16 Oct 2025 02:30:08 +0900 Subject: [PATCH 118/470] =?UTF-8?q?feat=20:=20=ED=8E=98=EC=9D=B4=EC=A7=95?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20#18?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/KupickManagerController.kt | 15 ++++++------ .../domain/kupick/facade/KupickFacade.kt | 14 +++++++---- .../kupick/repository/KupickRepository.kt | 7 ++++-- .../domain/kupick/service/KupickService.kt | 7 ++++-- .../onku/backend/global/page/PageResponse.kt | 23 +++++++++++++++++++ 5 files changed, 50 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/onku/backend/global/page/PageResponse.kt 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 index 5181169..612e0a9 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt @@ -5,12 +5,10 @@ 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.page.PageResponse import onku.backend.global.response.SuccessResponse 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 +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/kupick/manage") @@ -25,9 +23,12 @@ class KupickManagerController( ) fun submitApplication( year : Int, - month : Int - ) : ResponseEntity>> { - return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.showUpdate(year, month))) + month : Int, + @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(kupickFacade.showUpdate(year, month, safePage, size))) } @PostMapping("/approval") diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index f14a741..5b8b27b 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -5,9 +5,11 @@ 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.page.PageResponse import onku.backend.global.s3.dto.GetPreSignedUrlDto import onku.backend.global.s3.enums.FolderName import onku.backend.global.s3.service.S3Service +import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Component @Component @@ -45,11 +47,12 @@ class KupickFacade( ) } - fun showUpdate(year : Int, month : Int): List { - val profiles = kupickService.findAllAsShowUpdateResponse(year, month) - return profiles.map { p -> - val memberId = p.memberProfile.memberId!! // presign에 사용 - val profile = p.memberProfile // MemberProfile + fun showUpdate(year : Int, month : Int, page: Int, size: Int): PageResponse { + val pageRequest = PageRequest.of(page, size) + val profiles = kupickService.findAllAsShowUpdateResponse(year, month, pageRequest) + val dtoPage = profiles.map { p -> + val memberId = p.memberProfile.memberId!! + val profile = p.memberProfile val applicationUrl: String? = p.kupick.applicationImageUrl @@ -70,6 +73,7 @@ class KupickFacade( approval = p.kupick.approval ) } + return PageResponse.from(dtoPage) } fun decideApproval(kupickApprovalRequest: KupickApprovalRequest): Boolean { diff --git a/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt index 0418e6d..dece59c 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt @@ -4,6 +4,8 @@ import onku.backend.domain.kupick.Kupick 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.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 @@ -53,6 +55,7 @@ interface KupickRepository : JpaRepository { """) fun findAllWithProfile( @Param("start") start: LocalDateTime, - @Param("end") end: LocalDateTime - ): List + @Param("end") end: LocalDateTime, + pageable: Pageable + ): Page } \ 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 index 2d67bde..feccd08 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -8,6 +8,8 @@ import onku.backend.domain.member.Member import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode import onku.backend.global.time.TimeRangeUtil +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -53,11 +55,12 @@ class KupickService( } @Transactional(readOnly = true) - fun findAllAsShowUpdateResponse(year : Int, month : Int): List { + fun findAllAsShowUpdateResponse(year : Int, month : Int, pageRequest: PageRequest): Page { val monthObject = TimeRangeUtil.monthRange(year, month) return kupickRepository.findAllWithProfile( monthObject.startOfMonth, - monthObject.startOfNextMonth + monthObject.startOfNextMonth, + pageRequest ) } diff --git a/src/main/kotlin/onku/backend/global/page/PageResponse.kt b/src/main/kotlin/onku/backend/global/page/PageResponse.kt new file mode 100644 index 0000000..dbd532a --- /dev/null +++ b/src/main/kotlin/onku/backend/global/page/PageResponse.kt @@ -0,0 +1,23 @@ +package onku.backend.global.page + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.data.domain.Page + +data class PageResponse( + @Schema(description = "페이지 응답") + val data: List, + @Schema(description = "전체 페이지 수") + val totalPages: Int, + @Schema(description = "마지막 페이지 여부") + val isLastPage: Boolean, +) { + companion object { + fun from( + page: Page + ): PageResponse = PageResponse( + data = page.content, + totalPages = page.totalPages, + isLastPage = page.isLast + ) + } +} \ No newline at end of file From e3b82dd3d74fce51b261d4e45b29c079257a9fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 01:43:17 +0900 Subject: [PATCH 119/470] =?UTF-8?q?feat=20:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=EA=B4=80=EB=A0=A8=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/controller/SessionController.kt | 33 +++++++++++++++ .../dto/SessionAboutAbsenceResponse.kt | 8 ++++ .../domain/session/facade/SessionFacade.kt | 18 ++++++++ .../session/repository/SessionRepository.kt | 13 ++++++ .../SessionAboutAbsenceProjection.kt | 10 +++++ .../domain/session/service/SessionService.kt | 41 +++++++++++++++++++ 6 files changed, 123 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/session/controller/SessionController.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/service/SessionService.kt diff --git a/src/main/kotlin/onku/backend/domain/session/controller/SessionController.kt b/src/main/kotlin/onku/backend/domain/session/controller/SessionController.kt new file mode 100644 index 0000000..8d8bdf6 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/controller/SessionController.kt @@ -0,0 +1,33 @@ +package onku.backend.domain.session.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.session.dto.SessionAboutAbsenceResponse +import onku.backend.domain.session.facade.SessionFacade +import onku.backend.global.page.PageResponse +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/session") +@Tag(name = "세션 API", description = "세션 관련 API") +class SessionController( + private val sessionFacade: SessionFacade +) { + @GetMapping("/absence") + @Operation( + summary = "불참사유서 제출 페이지에서 세션 정보 조회", + description = "불참사유서 제출 페이지에서 세션 정보를 조회합니다." + ) + fun showSessionAboutAbsence( + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "5") size: Int + ) : ResponseEntity>> { + val safePage = if (page < 1) 0 else page - 1 + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showSessionAboutAbsence(safePage, size))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt b/src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt new file mode 100644 index 0000000..52bea66 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.session.dto + +data class SessionAboutAbsenceResponse( + val sessionId : Long?, + val title : String, + val week : Long, + val active : Boolean +) 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..192629d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -0,0 +1,18 @@ +package onku.backend.domain.session.facade + +import onku.backend.domain.session.dto.SessionAboutAbsenceResponse +import onku.backend.domain.session.service.SessionService +import onku.backend.global.page.PageResponse +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Component + +@Component +class SessionFacade( + private val sessionService: SessionService +) { + fun showSessionAboutAbsence(page: Int, size: Int): PageResponse { + val pageRequest = PageRequest.of(page, size) + val sessionPage = sessionService.getUpcomingSessionsForAbsence(pageRequest) + return PageResponse.from(sessionPage) + } +} \ 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 index 42f997a..28cf23c 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -1,6 +1,9 @@ package onku.backend.domain.session.repository import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.projection.SessionAboutAbsenceProjection +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.query.Param @@ -20,4 +23,14 @@ interface SessionRepository : CrudRepository { nativeQuery = true ) fun findOpenSession(@Param("now") now: LocalDateTime): Session? + + @Query(""" + SELECT s.id AS id, + s.title AS title, + s.week AS week, + s.startTime AS startTime + FROM Session s + WHERE s.startTime >= :now + """) + fun findUpcomingSessions(@Param("now") now: LocalDateTime, pageable: Pageable): Page } diff --git a/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt b/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt new file mode 100644 index 0000000..bc7fdb4 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.session.repository.projection + +import java.time.LocalDateTime + +interface SessionAboutAbsenceProjection { + val id: Long? + val title: String + val week: Long + val startTime: LocalDateTime +} \ 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..4255775 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -0,0 +1,41 @@ +package onku.backend.domain.session.service + +import onku.backend.domain.session.dto.SessionAboutAbsenceResponse +import onku.backend.domain.session.repository.SessionRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import java.time.DayOfWeek +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.temporal.TemporalAdjusters + +@Service +class SessionService( + private val sessionRepository: SessionRepository +) { + fun getUpcomingSessionsForAbsence(pageable: Pageable): Page { + val zone = ZoneId.of("Asia/Seoul") + val now = LocalDateTime.now(zone) + val today = now.toLocalDate() + + val isFriOrSat = today.dayOfWeek == DayOfWeek.FRIDAY || today.dayOfWeek == DayOfWeek.SATURDAY + val upcomingSaturday = if (isFriOrSat) + today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) + else null + + val sessions = sessionRepository.findUpcomingSessions(now, pageable) + + return sessions.map { s -> + val sessionDate = s.startTime.atZone(zone).toLocalDate() + val active = !(isFriOrSat && upcomingSaturday != null && sessionDate == upcomingSaturday) + + SessionAboutAbsenceResponse( + sessionId = s.id, + title = s.title, + week = s.week, + active = active + ) + } + } +} \ No newline at end of file From 069c8103fef555c1d7c556130b1526eb97b0b674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 02:41:21 +0900 Subject: [PATCH 120/470] =?UTF-8?q?feat=20:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=EC=A0=9C=EC=B6=9C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/absence/AbsenceReport.kt | 57 +++++++++++++++++++ .../absence/controller/AbsenceController.kt | 27 +++++++++ .../dto/request/SubmitAbsenceReportRequest.kt | 12 ++++ .../absence/enums/AbsenceReportApproval.kt | 5 ++ .../domain/absence/enums/AbsenceType.kt | 5 ++ .../domain/absence/facade/AbsenceFacade.kt | 25 ++++++++ .../repository/AbsenceReportRepository.kt | 7 +++ .../domain/absence/service/AbsenceService.kt | 26 +++++++++ .../domain/session/service/SessionService.kt | 11 ++++ .../backend/global/exception/ErrorCode.kt | 3 +- .../backend/global/s3/enums/FolderName.kt | 3 +- 11 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/enums/AbsenceReportApproval.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/enums/AbsenceType.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt 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..dfc7d43 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt @@ -0,0 +1,57 @@ +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.AbsenceType +import onku.backend.domain.member.Member +import onku.backend.domain.session.Session +import onku.backend.global.entity.BaseEntity + +@Entity +class AbsenceReport( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "absence_report_id") + val id: Long? = null, + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id") + val session : Session, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + val member : Member, + + @Column(name = "url") + val url : String, + + @Column(name = "status") + @Enumerated(EnumType.STRING) + val status : AbsenceType, + + @Column(name = "reason") + val reason : String, + + @Column(name = "approval") + @Enumerated(EnumType.STRING) + val approval : AbsenceReportApproval, +) : BaseEntity() { + companion object { + fun createAbsenceReport( + member: Member, + session : Session, + submitAbsenceReportRequest: SubmitAbsenceReportRequest, + fileKey : String, + ): AbsenceReport { + return AbsenceReport( + member = member, + session = session, + url = fileKey, + status = submitAbsenceReportRequest.absenceType, + reason = submitAbsenceReportRequest.reason, + approval = AbsenceReportApproval.SUBMIT + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt new file mode 100644 index 0000000..a8060e9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt @@ -0,0 +1,27 @@ +package onku.backend.domain.absence.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +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.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/absence") +@Tag(name = "불참사유서 관련 API") +class AbsenceController( + private val absenceFacade : AbsenceFacade +) { + @PostMapping("") + @Operation(summary = "불참 사유서 제출", description = "불참 사유서 제출하는 API 입니다") + fun submitAttendanceReport(@CurrentMember member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.submitAbsenceReport(member, submitAbsenceReportRequest))) + } +} \ No newline at end of file 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..ba6b362 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.absence.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import onku.backend.domain.absence.enums.AbsenceType + +data class SubmitAbsenceReportRequest( + @field:NotNull val sessionId : Long, + val absenceType : AbsenceType, + val reason : String, + @field:NotBlank val fileName : String +) \ 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/AbsenceType.kt b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceType.kt new file mode 100644 index 0000000..b55e77d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceType.kt @@ -0,0 +1,5 @@ +package onku.backend.domain.absence.enums + +enum class AbsenceType { + 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..94986ea --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -0,0 +1,25 @@ +package onku.backend.domain.absence.facade + +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.service.AbsenceService +import onku.backend.domain.member.Member +import onku.backend.domain.session.service.SessionService +import onku.backend.global.s3.dto.GetPreSignedUrlDto +import onku.backend.global.s3.enums.FolderName +import onku.backend.global.s3.service.S3Service +import org.springframework.stereotype.Component + +@Component +class AbsenceFacade( + private val absenceService : AbsenceService, + private val s3Service: S3Service, + private val sessionService: SessionService +) { + fun submitAbsenceReport(member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): GetPreSignedUrlDto { + val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name) + val session = sessionService.getById(submitAbsenceReportRequest.sessionId) + absenceService.submitAbsenceReport(member, submitAbsenceReportRequest, preSignedUrlDto.key, session) + return GetPreSignedUrlDto(preSignedUrlDto.preSignedUrl) + } + +} \ 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..61ff905 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -0,0 +1,7 @@ +package onku.backend.domain.absence.repository + +import onku.backend.domain.absence.AbsenceReport +import org.springframework.data.jpa.repository.JpaRepository + +interface AbsenceReportRepository : JpaRepository { +} \ 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..10e2cdf --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -0,0 +1,26 @@ +package onku.backend.domain.absence.service + +import onku.backend.domain.absence.AbsenceReport +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.repository.AbsenceReportRepository +import onku.backend.domain.member.Member +import onku.backend.domain.session.Session +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) { + absenceReportRepository.save( + AbsenceReport.createAbsenceReport( + member = member, + session = session, + submitAbsenceReportRequest, + fileKey + ) + ) + } +} \ 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 index 4255775..185879c 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -1,10 +1,15 @@ package onku.backend.domain.session.service +import onku.backend.domain.session.Session import onku.backend.domain.session.dto.SessionAboutAbsenceResponse import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode 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.DayOfWeek import java.time.LocalDateTime import java.time.ZoneId @@ -14,6 +19,7 @@ import java.time.temporal.TemporalAdjusters class SessionService( private val sessionRepository: SessionRepository ) { + @Transactional(readOnly = true) fun getUpcomingSessionsForAbsence(pageable: Pageable): Page { val zone = ZoneId.of("Asia/Seoul") val now = LocalDateTime.now(zone) @@ -38,4 +44,9 @@ class SessionService( ) } } + + @Transactional(readOnly = true) + fun getById(id : Long) : Session { + return sessionRepository.findByIdOrNull(id) ?: throw CustomException(ErrorCode.SESSION_NOT_FOUND) + } } \ 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 index 0963286..751a2b4 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -17,5 +17,6 @@ enum class ErrorCode( INVALID_FILE_EXTENSION("S3001", "올바르지 않은 파일 확장자 입니다.", HttpStatus.BAD_REQUEST), KUPICK_APPLICATION_FIRST("kupick001", "큐픽 신청부터 진행해주세요", HttpStatus.BAD_REQUEST), KUPICK_NOT_FOUND("kupick002", "해당 큐픽 객체를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST); + FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST), + SESSION_NOT_FOUND("session001", "해당 세션이 존재하지 않습니다.", HttpStatus.NOT_FOUND); } diff --git a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt index a033d48..f919f4f 100644 --- a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt +++ b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt @@ -2,5 +2,6 @@ package onku.backend.global.s3.enums enum class FolderName{ KUPICK_APPLICATION, - KUPICK_VIEW; + KUPICK_VIEW, + ABSENCE } \ No newline at end of file From 3a7f692c5a9b33f1cbb6ea5148f661eb73903d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 19:30:24 +0900 Subject: [PATCH 121/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20upsert=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/absence/AbsenceReport.kt | 30 ++++++++++++++++--- .../absence/controller/AbsenceController.kt | 4 ++- .../dto/request/SubmitAbsenceReportRequest.kt | 1 + .../domain/absence/facade/AbsenceFacade.kt | 16 ++++++++-- .../domain/absence/service/AbsenceService.kt | 15 ++++++++-- .../absence/validator/AbsenceValidator.kt | 30 +++++++++++++++++++ .../session/repository/SessionRepository.kt | 3 +- .../SessionAboutAbsenceProjection.kt | 10 ------- .../domain/session/service/SessionService.kt | 21 +++++-------- 9 files changed, 94 insertions(+), 36 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt delete mode 100644 src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt diff --git a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt index dfc7d43..39587e4 100644 --- a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt +++ b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt @@ -7,8 +7,18 @@ import onku.backend.domain.absence.enums.AbsenceType 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) @@ -17,21 +27,21 @@ class AbsenceReport( @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "session_id") - val session : Session, + var session : Session, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") val member : Member, @Column(name = "url") - val url : String, + var url : String, @Column(name = "status") @Enumerated(EnumType.STRING) - val status : AbsenceType, + var status : AbsenceType, @Column(name = "reason") - val reason : String, + var reason : String, @Column(name = "approval") @Enumerated(EnumType.STRING) @@ -54,4 +64,16 @@ class AbsenceReport( ) } } + + fun updateAbsenceReport( + submitAbsenceReportRequest: SubmitAbsenceReportRequest, + fileKey: String, + session: Session + ) { + this.session = session + this.reason = submitAbsenceReportRequest.reason + this.url = fileKey + this.status = submitAbsenceReportRequest.absenceType + this.updatedAt = LocalDateTime.now() + } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt index a8060e9..0eacb0d 100644 --- a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt +++ b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt @@ -10,6 +10,7 @@ import onku.backend.global.response.SuccessResponse import onku.backend.global.s3.dto.GetPreSignedUrlDto import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -21,7 +22,8 @@ class AbsenceController( ) { @PostMapping("") @Operation(summary = "불참 사유서 제출", description = "불참 사유서 제출하는 API 입니다") - fun submitAttendanceReport(@CurrentMember member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { + fun submitAttendanceReport(@CurrentMember member: Member, + @RequestBody submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.submitAbsenceReport(member, submitAbsenceReportRequest))) } } \ No newline at end of file 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 index ba6b362..8db6cee 100644 --- a/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt +++ b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotNull import onku.backend.domain.absence.enums.AbsenceType data class SubmitAbsenceReportRequest( + val absenceReportId : Long?, @field:NotNull val sessionId : Long, val absenceType : AbsenceType, val reason : String, diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index 94986ea..08e9f0f 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -2,8 +2,11 @@ package onku.backend.domain.absence.facade import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest import onku.backend.domain.absence.service.AbsenceService +import onku.backend.domain.absence.validator.AbsenceValidator import onku.backend.domain.member.Member import onku.backend.domain.session.service.SessionService +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode import onku.backend.global.s3.dto.GetPreSignedUrlDto import onku.backend.global.s3.enums.FolderName import onku.backend.global.s3.service.S3Service @@ -13,11 +16,20 @@ import org.springframework.stereotype.Component class AbsenceFacade( private val absenceService : AbsenceService, private val s3Service: S3Service, - private val sessionService: SessionService + private val sessionService: SessionService, + private val absenceValidator: AbsenceValidator ) { fun submitAbsenceReport(member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): GetPreSignedUrlDto { - val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name) val session = sessionService.getById(submitAbsenceReportRequest.sessionId) + when { + absenceValidator.isPastSession(session) -> { + throw CustomException(ErrorCode.SESSION_PAST) + } + absenceValidator.isImminentSession(session) -> { + throw CustomException(ErrorCode.SESSION_IMMINENT) + } + } + val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name) absenceService.submitAbsenceReport(member, submitAbsenceReportRequest, preSignedUrlDto.key, session) return GetPreSignedUrlDto(preSignedUrlDto.preSignedUrl) } diff --git a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt index 10e2cdf..b0a053f 100644 --- a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -5,22 +5,31 @@ import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest import onku.backend.domain.absence.repository.AbsenceReportRepository import onku.backend.domain.member.Member import onku.backend.domain.session.Session +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service class AbsenceService( - private val absenceReportRepository: AbsenceReportRepository + private val absenceReportRepository: AbsenceReportRepository, + ) { @Transactional fun submitAbsenceReport(member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest, fileKey : String, session : Session) { - absenceReportRepository.save( + 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, fileKey ) - ) + } + absenceReportRepository.save(report) } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt b/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt new file mode 100644 index 0000000..be258c1 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt @@ -0,0 +1,30 @@ +package onku.backend.domain.absence.validator + +import onku.backend.domain.session.Session +import org.springframework.stereotype.Component +import java.time.DayOfWeek +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.temporal.TemporalAdjusters + +@Component +class AbsenceValidator { + + private val zone: ZoneId = ZoneId.of("Asia/Seoul") + + /** 지난 세션 여부 */ + fun isPastSession(session: Session, now: LocalDateTime = LocalDateTime.now(zone)): Boolean { + return session.endTime.isBefore(now) + } + + /** 금/토에 바로 앞 토요일 세션인지 여부 */ + fun isImminentSession(session: Session, now: LocalDateTime = LocalDateTime.now(zone)): Boolean { + val today = now.toLocalDate() + val isFriOrSat = today.dayOfWeek == DayOfWeek.FRIDAY || today.dayOfWeek == DayOfWeek.SATURDAY + if (!isFriOrSat) return false + + val upcomingSaturday = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) + val sessionDate = session.startTime.atZone(zone).toLocalDate() + return sessionDate == upcomingSaturday + } +} \ 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 index 28cf23c..117fea5 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -1,7 +1,6 @@ package onku.backend.domain.session.repository import onku.backend.domain.session.Session -import onku.backend.domain.session.repository.projection.SessionAboutAbsenceProjection import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.Query @@ -32,5 +31,5 @@ interface SessionRepository : CrudRepository { FROM Session s WHERE s.startTime >= :now """) - fun findUpcomingSessions(@Param("now") now: LocalDateTime, pageable: Pageable): Page + fun findUpcomingSessions(@Param("now") now: LocalDateTime, pageable: Pageable): Page } diff --git a/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt b/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt deleted file mode 100644 index bc7fdb4..0000000 --- a/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt +++ /dev/null @@ -1,10 +0,0 @@ -package onku.backend.domain.session.repository.projection - -import java.time.LocalDateTime - -interface SessionAboutAbsenceProjection { - val id: Long? - val title: String - val week: Long - val startTime: LocalDateTime -} \ 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 index 185879c..c3b571d 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -1,5 +1,6 @@ package onku.backend.domain.session.service +import onku.backend.domain.absence.validator.AbsenceValidator import onku.backend.domain.session.Session import onku.backend.domain.session.dto.SessionAboutAbsenceResponse import onku.backend.domain.session.repository.SessionRepository @@ -10,32 +11,24 @@ 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.DayOfWeek +import java.time.Clock import java.time.LocalDateTime import java.time.ZoneId -import java.time.temporal.TemporalAdjusters @Service class SessionService( - private val sessionRepository: SessionRepository + private val sessionRepository: SessionRepository, + private val absenceValidator: AbsenceValidator, + private val clock: Clock = Clock.system(ZoneId.of("Asia/Seoul")) ) { @Transactional(readOnly = true) fun getUpcomingSessionsForAbsence(pageable: Pageable): Page { - val zone = ZoneId.of("Asia/Seoul") - val now = LocalDateTime.now(zone) - val today = now.toLocalDate() - - val isFriOrSat = today.dayOfWeek == DayOfWeek.FRIDAY || today.dayOfWeek == DayOfWeek.SATURDAY - val upcomingSaturday = if (isFriOrSat) - today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) - else null + val now = LocalDateTime.now(clock) val sessions = sessionRepository.findUpcomingSessions(now, pageable) return sessions.map { s -> - val sessionDate = s.startTime.atZone(zone).toLocalDate() - val active = !(isFriOrSat && upcomingSaturday != null && sessionDate == upcomingSaturday) - + val active = absenceValidator.isImminentSession(s, now) SessionAboutAbsenceResponse( sessionId = s.id, title = s.title, From 907f766dcb7d43a865e621f279157ec211630114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 19:30:44 +0900 Subject: [PATCH 122/470] =?UTF-8?q?feat=20:=20=EB=AC=B4=EA=B2=B0=EC=84=B1?= =?UTF-8?q?=20=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=20DB=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=ED=95=B8=EB=93=A4=EB=A7=81=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/exception/ErrorCode.kt | 5 ++++- .../backend/global/exception/ExceptionAdvice.kt | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt index 751a2b4..39e44a0 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -18,5 +18,8 @@ enum class ErrorCode( KUPICK_APPLICATION_FIRST("kupick001", "큐픽 신청부터 진행해주세요", HttpStatus.BAD_REQUEST), KUPICK_NOT_FOUND("kupick002", "해당 큐픽 객체를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST), - SESSION_NOT_FOUND("session001", "해당 세션이 존재하지 않습니다.", HttpStatus.NOT_FOUND); + SESSION_NOT_FOUND("session001", "해당 세션이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + SESSION_PAST("session002", "이미 지난 세션입니다.", HttpStatus.BAD_REQUEST), + SESSION_IMMINENT("session003", "불참제출서 제출은 목요일까지입니다.", 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 index 4edefa9..b65d762 100644 --- a/src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt +++ b/src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt @@ -119,4 +119,17 @@ class ExceptionAdvice { 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) + } + } From 2d705b578bf1deb4a978ad0b042a3846cbf2a836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 19:55:15 +0900 Subject: [PATCH 123/470] =?UTF-8?q?feat=20:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=EC=A0=9C=EC=B6=9C=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../absence/controller/AbsenceController.kt | 20 ++++++++++++----- .../response/GetMyAbsenceReportResponse.kt | 14 ++++++++++++ .../domain/absence/facade/AbsenceFacade.kt | 19 ++++++++++++++++ .../repository/AbsenceReportRepository.kt | 22 +++++++++++++++++++ .../projection/GetMyAbsenceReportView.kt | 14 ++++++++++++ .../domain/absence/service/AbsenceService.kt | 18 +++++++++++++++ .../attendance/service/AttendanceService.kt | 1 - 7 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/absence/dto/response/GetMyAbsenceReportResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/repository/projection/GetMyAbsenceReportView.kt diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt index 0eacb0d..b6e0944 100644 --- a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt +++ b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt @@ -3,16 +3,15 @@ package onku.backend.domain.absence.controller import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag 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.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.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/absence") @@ -22,8 +21,17 @@ class AbsenceController( ) { @PostMapping("") @Operation(summary = "불참 사유서 제출", description = "불참 사유서 제출하는 API 입니다") - fun submitAttendanceReport(@CurrentMember member: Member, - @RequestBody submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { + fun submitAbsenceReport(@CurrentMember member: Member, + @RequestBody submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.submitAbsenceReport(member, submitAbsenceReportRequest))) } + + @GetMapping("") + @Operation(summary = "불참 사유서 제출내역 조회", description = "내가 낸 불참 사유서 제출내역을 조회합니다.") + fun getMyAbsenceReport(@CurrentMember member: Member, + @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(absenceFacade.getMyAbsenceReport(member, safePage, size))) + } } \ No newline at end of file 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..d78e155 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMyAbsenceReportResponse.kt @@ -0,0 +1,14 @@ +package onku.backend.domain.absence.dto.response + +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.absence.enums.AbsenceType +import java.time.LocalDateTime + +data class GetMyAbsenceReportResponse( + val absenceReportId : Long, + val absenceType : AbsenceType, + val absenceReportApproval : AbsenceReportApproval, + val submitDateTime : LocalDateTime, + val sessionTitle : String, + val sessionStartDateTime: LocalDateTime +) diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index 08e9f0f..7b00fe7 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -1,15 +1,18 @@ package onku.backend.domain.absence.facade import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.dto.response.GetMyAbsenceReportResponse import onku.backend.domain.absence.service.AbsenceService import onku.backend.domain.absence.validator.AbsenceValidator import onku.backend.domain.member.Member import onku.backend.domain.session.service.SessionService import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode +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.service.S3Service +import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Component @Component @@ -34,4 +37,20 @@ class AbsenceFacade( return GetPreSignedUrlDto(preSignedUrlDto.preSignedUrl) } + fun getMyAbsenceReport(member: Member, page: Int, size: Int): PageResponse { + val pageRequest = PageRequest.of(page, size) + val absenceReportPage = absenceService.getMyAbsenceReports(member, pageRequest) + val responses = absenceReportPage.map { v -> + GetMyAbsenceReportResponse( + absenceReportId = v.absenceReportId, + absenceType = v.absenceType, + absenceReportApproval = v.absenceReportApproval, + submitDateTime = v.submitDateTime, + sessionTitle = v.sessionTitle, + sessionStartDateTime = v.sessionStartDateTime + ) + } + return PageResponse.from(responses) + } + } \ 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 index 61ff905..954201c 100644 --- a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -1,7 +1,29 @@ 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.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 interface AbsenceReportRepository : JpaRepository { + @Query(""" + select + ar.id as absenceReportId, + ar.status as absenceType, + ar.approval as absenceReportApproval, + ar.createdAt as submitDateTime, + s.title as sessionTitle, + s.startTime as sessionStartDateTime + from AbsenceReport ar + join ar.session s + where ar.member = :member + """) + fun findMyAbsenceReports( + @Param("member") member: Member, + pageable: Pageable + ): Page } \ 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..2c5e8d2 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/repository/projection/GetMyAbsenceReportView.kt @@ -0,0 +1,14 @@ +package onku.backend.domain.absence.repository.projection + +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.absence.enums.AbsenceType +import java.time.LocalDateTime + +interface GetMyAbsenceReportView { + fun getAbsenceReportId(): Long + fun getAbsenceType(): AbsenceType + fun getAbsenceReportApproval(): AbsenceReportApproval + fun getSubmitDateTime(): LocalDateTime + fun getSessionTitle(): String + fun getSessionStartDateTime(): LocalDateTime +} \ 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 index b0a053f..26ba326 100644 --- a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -2,9 +2,12 @@ package onku.backend.domain.absence.service import onku.backend.domain.absence.AbsenceReport 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 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 @@ -32,4 +35,19 @@ class AbsenceService( } absenceReportRepository.save(report) } + + @Transactional(readOnly = true) + fun getMyAbsenceReports(member: Member, pageable: Pageable): Page { + val page = absenceReportRepository.findMyAbsenceReports(member, pageable) + return page.map { v -> + GetMyAbsenceReportResponse( + absenceReportId = v.getAbsenceReportId(), + absenceType = v.getAbsenceType(), + absenceReportApproval = v.getAbsenceReportApproval(), + submitDateTime = v.getSubmitDateTime(), + sessionTitle = v.getSessionTitle(), + sessionStartDateTime = v.getSessionStartDateTime() + ) + } + } } \ 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 index 85ed8f9..2b60a2d 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -5,7 +5,6 @@ import onku.backend.domain.attendance.dto.* import onku.backend.domain.attendance.enums.AttendanceStatus import onku.backend.domain.attendance.repository.AttendanceRepository import onku.backend.domain.member.Member -import onku.backend.domain.member.enums.Role import onku.backend.domain.member.repository.MemberProfileRepository import onku.backend.domain.session.repository.SessionRepository import onku.backend.global.exception.CustomException From 7c98b0c5483abe98f0b8ac26dfcd6e9a54a8b960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 20:03:56 +0900 Subject: [PATCH 124/470] =?UTF-8?q?refactor=20:=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EB=AC=B8=20=EB=B3=80=EA=B2=BD(=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=EC=85=98=20=EC=82=AC=EC=9A=A9x)=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/session/repository/SessionRepository.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index 117fea5..1d9b026 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -24,10 +24,7 @@ interface SessionRepository : CrudRepository { fun findOpenSession(@Param("now") now: LocalDateTime): Session? @Query(""" - SELECT s.id AS id, - s.title AS title, - s.week AS week, - s.startTime AS startTime + SELECT s FROM Session s WHERE s.startTime >= :now """) From da1c4b9aa0dcc95be3ea5cd0343d555795ce9dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 18 Oct 2025 13:56:24 +0900 Subject: [PATCH 125/470] =?UTF-8?q?chore:=20enum=EC=97=90=20@Schema?= =?UTF-8?q?=EB=A1=9C=20Swagger=20=EB=AC=B8=EC=84=9C=ED=99=94=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EA=B8=B0=20#27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/enums/AttendanceStatus.kt | 12 +++++++++++- .../domain/member/enums/ApprovalStatus.kt | 12 +++++++++++- .../onku/backend/domain/member/enums/Part.kt | 13 ++++++++++++- .../onku/backend/domain/member/enums/Role.kt | 17 +++++++++++++---- .../backend/domain/member/enums/SocialType.kt | 11 ++++++++++- .../domain/session/enums/SessionCategory.kt | 11 ++++++++++- 6 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt index cde8d72..a161cd9 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt @@ -1,3 +1,13 @@ package onku.backend.domain.attendance.enums -enum class AttendanceStatus { PRESENT, ABSENT, LATE } \ No newline at end of file +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = "출석 상태", + example = "PRESENT" +) +enum class AttendanceStatus { + @Schema(description = "출석(정시)") PRESENT, + @Schema(description = "결석") ABSENT, + @Schema(description = "지각") LATE +} \ 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 index ebdd70a..bd3883b 100644 --- a/src/main/kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt +++ b/src/main/kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt @@ -1,3 +1,13 @@ package onku.backend.domain.member.enums -enum class ApprovalStatus { PENDING, APPROVED, REJECTED } \ No newline at end of file +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = "회원가입 승인 상태", + example = "PENDING" +) +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 index 9774bf9..2f61d33 100644 --- a/src/main/kotlin/onku/backend/domain/member/enums/Part.kt +++ b/src/main/kotlin/onku/backend/domain/member/enums/Part.kt @@ -1,3 +1,14 @@ package onku.backend.domain.member.enums -enum class Part { BACKEND, FRONTEND, DESIGN, PLANNING } \ No newline at end of file +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 index 3deb000..f64acb7 100644 --- a/src/main/kotlin/onku/backend/domain/member/enums/Role.kt +++ b/src/main/kotlin/onku/backend/domain/member/enums/Role.kt @@ -1,12 +1,21 @@ package onku.backend.domain.member.enums +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = "사용자 권한 종류 (계층: MANAGEMENT ⟶ ADMIN ⟶ USER ⟶ GUEST)", + example = "USER" +) enum class Role { - GUEST, USER, ADMIN, MANAGEMENT; + @Schema(description = "게스트(온보딩 전용)") GUEST, + @Schema(description = "일반 사용자") USER, + @Schema(description = "운영진(사용자 권한 포함)") ADMIN, + @Schema(description = "경영/관리(운영진+사용자 권한 포함)") MANAGEMENT; fun authorities(): List = when (this) { GUEST -> listOf("GUEST") USER -> listOf("USER") - ADMIN -> listOf("ADMIN","USER") - MANAGEMENT -> listOf("MANAGEMENT","ADMIN","USER") + ADMIN -> listOf("ADMIN", "USER") + MANAGEMENT -> listOf("MANAGEMENT", "ADMIN", "USER") } -} \ No newline at end of file +} diff --git a/src/main/kotlin/onku/backend/domain/member/enums/SocialType.kt b/src/main/kotlin/onku/backend/domain/member/enums/SocialType.kt index dfbbc31..2085f11 100644 --- a/src/main/kotlin/onku/backend/domain/member/enums/SocialType.kt +++ b/src/main/kotlin/onku/backend/domain/member/enums/SocialType.kt @@ -1,3 +1,12 @@ package onku.backend.domain.member.enums -enum class SocialType { KAKAO, APPLE } +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/session/enums/SessionCategory.kt b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt index d7d9974..5d36b2f 100644 --- a/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt +++ b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt @@ -1,3 +1,12 @@ package onku.backend.domain.session.enums -enum class SessionCategory { GENERAL, HOLIDAY } \ No newline at end of file +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = "세션 종류", + example = "GENERAL" +) +enum class SessionCategory { + @Schema(description = "일반 세션") GENERAL, + @Schema(description = "공휴일 특별 세션 (가산점 1점)") HOLIDAY +} \ No newline at end of file From 971517a3a4326bb55f6a72f4733ba6b7c5cab642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 18 Oct 2025 13:57:19 +0900 Subject: [PATCH 126/470] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=85=EC=9D=B4=20En?= =?UTF-8?q?um=EC=9D=B4=20=EC=95=84=EB=8B=8C=20String=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=EC=9E=AC=EB=90=98=EC=96=B4=EC=9E=88=EB=8A=94?= =?UTF-8?q?=20dto=20=EC=88=98=EC=A0=95=20#27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt b/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt index e9c7d07..4ccf9f1 100644 --- a/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt +++ b/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt @@ -1,9 +1,10 @@ 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: String? = null, + val role: Role? = null, ) \ No newline at end of file From 2ce36e05c59ff3598770a67b5bed4aa4f2edb24b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 18 Oct 2025 13:57:53 +0900 Subject: [PATCH 127/470] =?UTF-8?q?fix:=20dto=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20service=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt index 9ac95cf..b7ca9ce 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -66,7 +66,7 @@ class AuthServiceImpl( AuthLoginResult( status = ApprovalStatus.APPROVED, memberId = member.id, - role = member.role.name + role = member.role ) ) ) From 32bd11f127b5c43d85cb6b0ad53bec3bc78286e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 18 Oct 2025 18:03:45 +0900 Subject: [PATCH 128/470] =?UTF-8?q?refactor=20:=20=ED=9C=B4=ED=9A=8C=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=EB=8F=84=20=EC=84=B8=EC=85=98=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/session/enums/SessionCategory.kt | 2 +- .../domain/session/repository/SessionRepository.kt | 9 +++++++-- .../backend/domain/session/service/SessionService.kt | 6 +++--- .../validator/SessionValidator.kt} | 10 ++++++++-- .../kotlin/onku/backend/global/exception/ErrorCode.kt | 1 + 5 files changed, 20 insertions(+), 8 deletions(-) rename src/main/kotlin/onku/backend/domain/{absence/validator/AbsenceValidator.kt => session/validator/SessionValidator.kt} (78%) diff --git a/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt index d7d9974..2f6cf2f 100644 --- a/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt +++ b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt @@ -1,3 +1,3 @@ package onku.backend.domain.session.enums -enum class SessionCategory { GENERAL, HOLIDAY } \ No newline at end of file +enum class SessionCategory { GENERAL, HOLIDAY, REST } \ 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 index 1d9b026..1ccadaa 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -1,6 +1,7 @@ package onku.backend.domain.session.repository import onku.backend.domain.session.Session +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.Query @@ -26,7 +27,11 @@ interface SessionRepository : CrudRepository { @Query(""" SELECT s FROM Session s - WHERE s.startTime >= :now + WHERE s.startTime >= :now AND s.category <> :restCategory """) - fun findUpcomingSessions(@Param("now") now: LocalDateTime, pageable: Pageable): Page + fun findUpcomingSessions( + @Param("now") now: LocalDateTime, + pageable: Pageable, + @Param("restCategory") restCategory: SessionCategory = SessionCategory.REST + ): Page } diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index c3b571d..539246a 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -1,6 +1,6 @@ package onku.backend.domain.session.service -import onku.backend.domain.absence.validator.AbsenceValidator +import onku.backend.domain.session.validator.SessionValidator import onku.backend.domain.session.Session import onku.backend.domain.session.dto.SessionAboutAbsenceResponse import onku.backend.domain.session.repository.SessionRepository @@ -18,7 +18,7 @@ import java.time.ZoneId @Service class SessionService( private val sessionRepository: SessionRepository, - private val absenceValidator: AbsenceValidator, + private val sessionValidator: SessionValidator, private val clock: Clock = Clock.system(ZoneId.of("Asia/Seoul")) ) { @Transactional(readOnly = true) @@ -28,7 +28,7 @@ class SessionService( val sessions = sessionRepository.findUpcomingSessions(now, pageable) return sessions.map { s -> - val active = absenceValidator.isImminentSession(s, now) + val active = sessionValidator.isImminentSession(s, now) SessionAboutAbsenceResponse( sessionId = s.id, title = s.title, diff --git a/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt b/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt similarity index 78% rename from src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt rename to src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt index be258c1..447d65f 100644 --- a/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt +++ b/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt @@ -1,6 +1,7 @@ -package onku.backend.domain.absence.validator +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 @@ -8,7 +9,7 @@ import java.time.ZoneId import java.time.temporal.TemporalAdjusters @Component -class AbsenceValidator { +class SessionValidator { private val zone: ZoneId = ZoneId.of("Asia/Seoul") @@ -27,4 +28,9 @@ class AbsenceValidator { val sessionDate = session.startTime.atZone(zone).toLocalDate() return sessionDate == upcomingSaturday } + + /** 휴회 세션인지 여부 */ + 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/exception/ErrorCode.kt b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt index 39e44a0..cf04a06 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -21,5 +21,6 @@ enum class ErrorCode( 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), SQL_INTEGRITY_VIOLATION("sql001", "무결성 제약조건을 위반하였습니다.", HttpStatus.BAD_REQUEST); } From c953b9d6ba55924f3e9d400cc4aaac3be9b4036f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 18 Oct 2025 18:05:42 +0900 Subject: [PATCH 129/470] =?UTF-8?q?refactor=20:=20=EB=B6=88=EC=B0=B8?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EC=84=9C=20=EC=A0=9C=EC=B6=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A7=80=EA=B0=81=EC=8B=9C=EA=B0=84=EA=B3=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=87=B4=EC=8B=9C=EA=B0=84=EB=8F=84=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EB=B0=9B=EA=B2=8C=20=EA=B5=AC=ED=98=84=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/absence/AbsenceReport.kt | 12 +++- .../absence/controller/AbsenceController.kt | 3 +- .../dto/annotation/ValidAbsenceReport.kt | 15 +++++ .../dto/request/SubmitAbsenceReportRequest.kt | 8 ++- .../domain/absence/facade/AbsenceFacade.kt | 12 ++-- .../validator/AbsenceReportValidator.kt | 55 +++++++++++++++++++ 6 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/absence/dto/annotation/ValidAbsenceReport.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/validator/AbsenceReportValidator.kt diff --git a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt index 39587e4..590d0b8 100644 --- a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt +++ b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt @@ -46,6 +46,12 @@ class AbsenceReport( @Column(name = "approval") @Enumerated(EnumType.STRING) val approval : AbsenceReportApproval, + + @Column(name = "lateDateTime") + var lateDateTime: LocalDateTime?, + + @Column(name = "leaveDateTime") + var leaveDateTime: LocalDateTime? ) : BaseEntity() { companion object { fun createAbsenceReport( @@ -60,7 +66,9 @@ class AbsenceReport( url = fileKey, status = submitAbsenceReportRequest.absenceType, reason = submitAbsenceReportRequest.reason, - approval = AbsenceReportApproval.SUBMIT + approval = AbsenceReportApproval.SUBMIT, + leaveDateTime = submitAbsenceReportRequest.leaveDateTime, + lateDateTime = submitAbsenceReportRequest.lateDateTime ) } } @@ -75,5 +83,7 @@ class AbsenceReport( this.url = fileKey this.status = submitAbsenceReportRequest.absenceType this.updatedAt = LocalDateTime.now() + this.leaveDateTime = submitAbsenceReportRequest.leaveDateTime + this.lateDateTime = submitAbsenceReportRequest.lateDateTime } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt index b6e0944..f829e1a 100644 --- a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt +++ b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt @@ -2,6 +2,7 @@ package onku.backend.domain.absence.controller 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 @@ -22,7 +23,7 @@ class AbsenceController( @PostMapping("") @Operation(summary = "불참 사유서 제출", description = "불참 사유서 제출하는 API 입니다") fun submitAbsenceReport(@CurrentMember member: Member, - @RequestBody submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { + @RequestBody @Valid submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.submitAbsenceReport(member, submitAbsenceReportRequest))) } 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/SubmitAbsenceReportRequest.kt b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt index 8db6cee..260b616 100644 --- a/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt +++ b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt @@ -2,12 +2,16 @@ package onku.backend.domain.absence.dto.request import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull +import onku.backend.domain.absence.dto.annotation.ValidAbsenceReport import onku.backend.domain.absence.enums.AbsenceType - +import java.time.LocalDateTime +@ValidAbsenceReport data class SubmitAbsenceReportRequest( val absenceReportId : Long?, @field:NotNull val sessionId : Long, val absenceType : AbsenceType, val reason : String, - @field:NotBlank val fileName : String + @field:NotBlank 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/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index 7b00fe7..bb35979 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -3,7 +3,7 @@ package onku.backend.domain.absence.facade import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest import onku.backend.domain.absence.dto.response.GetMyAbsenceReportResponse import onku.backend.domain.absence.service.AbsenceService -import onku.backend.domain.absence.validator.AbsenceValidator +import onku.backend.domain.session.validator.SessionValidator import onku.backend.domain.member.Member import onku.backend.domain.session.service.SessionService import onku.backend.global.exception.CustomException @@ -20,17 +20,21 @@ class AbsenceFacade( private val absenceService : AbsenceService, private val s3Service: S3Service, private val sessionService: SessionService, - private val absenceValidator: AbsenceValidator + private val sessionValidator: SessionValidator ) { fun submitAbsenceReport(member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): GetPreSignedUrlDto { val session = sessionService.getById(submitAbsenceReportRequest.sessionId) + //세션 검증 when { - absenceValidator.isPastSession(session) -> { + sessionValidator.isPastSession(session) -> { throw CustomException(ErrorCode.SESSION_PAST) } - absenceValidator.isImminentSession(session) -> { + sessionValidator.isImminentSession(session) -> { throw CustomException(ErrorCode.SESSION_IMMINENT) } + sessionValidator.isRestSession(session) -> { + throw CustomException(ErrorCode.INVALID_SESSION) + } } val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name) absenceService.submitAbsenceReport(member, submitAbsenceReportRequest, preSignedUrlDto.key, session) 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..e2512dd --- /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.AbsenceType + +class AbsenceReportValidator : ConstraintValidator { + override fun isValid( + value: SubmitAbsenceReportRequest?, + context: ConstraintValidatorContext + ): Boolean { + if (value == null) return true + + val type = value.absenceType + val late = value.lateDateTime + val leave = value.leaveDateTime + + // 공통적으로 여러 에러메시지를 누적 가능하게 설정 + context.disableDefaultConstraintViolation() + + return when (type) { + AbsenceType.LATE -> { + if (leave != null) { + context.buildConstraintViolationWithTemplate("지각일 경우 leaveDateTime은 비워야 합니다.") + .addPropertyNode("leaveDateTime").addConstraintViolation() + false + } else true + } + AbsenceType.EARLY_LEAVE -> { + if (late != null) { + context.buildConstraintViolationWithTemplate("조퇴일 경우 lateDateTime은 비워야 합니다.") + .addPropertyNode("lateDateTime").addConstraintViolation() + false + } else true + } + AbsenceType.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 From 1b7c02b878c6bd5656bff09b083e72a33cd91bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 18 Oct 2025 18:06:54 +0900 Subject: [PATCH 130/470] =?UTF-8?q?chore=20:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=EC=97=90=EB=9F=AC=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/exception/ErrorCode.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt index cf04a06..1f7459d 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -20,7 +20,7 @@ enum class ErrorCode( FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST), SESSION_NOT_FOUND("session001", "해당 세션이 존재하지 않습니다.", HttpStatus.NOT_FOUND), SESSION_PAST("session002", "이미 지난 세션입니다.", HttpStatus.BAD_REQUEST), - SESSION_IMMINENT("session003", "불참제출서 제출은 목요일까지입니다.", HttpStatus.BAD_REQUEST), + SESSION_IMMINENT("session003", "불참사유서 제출은 목요일까지입니다.", HttpStatus.BAD_REQUEST), INVALID_SESSION("session004", "유효한 세션이 아닙니다.", HttpStatus.BAD_REQUEST), SQL_INTEGRITY_VIOLATION("sql001", "무결성 제약조건을 위반하였습니다.", HttpStatus.BAD_REQUEST); } From a99b9da5dcd9cca06cb5247af9b659cefd376882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 13:20:05 +0900 Subject: [PATCH 131/470] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20enum=EC=97=90=20=ED=9C=B4=ED=9A=8C=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/session/enums/SessionCategory.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt index 5d36b2f..2a4ffbe 100644 --- a/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt +++ b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt @@ -8,5 +8,6 @@ import io.swagger.v3.oas.annotations.media.Schema ) enum class SessionCategory { @Schema(description = "일반 세션") GENERAL, - @Schema(description = "공휴일 특별 세션 (가산점 1점)") HOLIDAY + @Schema(description = "공휴일 특별 세션 (가산점 1점)") HOLIDAY, + @Schema(description = "휴회 세션") REST, } \ No newline at end of file From 468cfab274ab51a2d0ac0e5d990fc7f84e23e1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 20 Oct 2025 00:30:19 +0900 Subject: [PATCH 132/470] =?UTF-8?q?chore:=20exposedHeaders=EC=97=90=20X-Re?= =?UTF-8?q?fresh-Token=20=EC=B6=94=EA=B0=80=20#38?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt b/src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt index c856c1c..1ec173f 100644 --- a/src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt +++ b/src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt @@ -21,7 +21,7 @@ class CustomCorsConfig { configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS") configuration.allowedHeaders = listOf("*") configuration.allowCredentials = true - configuration.exposedHeaders = listOf("Authorization", "Set-Cookie") + configuration.exposedHeaders = listOf("Authorization", "Set-Cookie", "X-Refresh-Token") configuration.maxAge = 3600 val source = UrlBasedCorsConfigurationSource() source.registerCorsConfiguration("/**", configuration) From 86abeba02b385bf83ae807fb63d91f673beb120b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 20 Oct 2025 19:49:15 +0900 Subject: [PATCH 133/470] =?UTF-8?q?refactor=20:=20=ED=81=90=ED=94=BD=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD/=EC=8B=9C=EC=B2=AD=20=EC=84=9C=EB=A5=98=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20POST?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20#37?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/kupick/controller/user/KupickController.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 0394713..ea92b7d 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt @@ -10,6 +10,7 @@ import onku.backend.global.response.SuccessResponse import onku.backend.global.s3.dto.GetPreSignedUrlDto 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 @@ -19,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController class KupickController( private val kupickFacade: KupickFacade ) { - @GetMapping("/application") + @PostMapping("/application") @Operation( summary = "큐픽 신청 서류 제출", description = "큐픽 신청용 서류 제출 signedUrl 반환" @@ -30,7 +31,7 @@ class KupickController( return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.submitApplication(member, fileName))) } - @GetMapping("/view") + @PostMapping("/view") @Operation( summary = "큐픽 시청 서류 제출", description = "큐픽 시청 증빙 서류 제출 signedUrl 반환" From 22b0749d0ff95435cae9bbf4af0999d24f65c7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 01:43:17 +0900 Subject: [PATCH 134/470] =?UTF-8?q?feat=20:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=EA=B4=80=EB=A0=A8=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/controller/SessionController.kt | 33 +++++++++++++++ .../dto/SessionAboutAbsenceResponse.kt | 8 ++++ .../domain/session/facade/SessionFacade.kt | 18 ++++++++ .../session/repository/SessionRepository.kt | 13 ++++++ .../SessionAboutAbsenceProjection.kt | 10 +++++ .../domain/session/service/SessionService.kt | 41 +++++++++++++++++++ 6 files changed, 123 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/session/controller/SessionController.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/service/SessionService.kt diff --git a/src/main/kotlin/onku/backend/domain/session/controller/SessionController.kt b/src/main/kotlin/onku/backend/domain/session/controller/SessionController.kt new file mode 100644 index 0000000..8d8bdf6 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/controller/SessionController.kt @@ -0,0 +1,33 @@ +package onku.backend.domain.session.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.session.dto.SessionAboutAbsenceResponse +import onku.backend.domain.session.facade.SessionFacade +import onku.backend.global.page.PageResponse +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/session") +@Tag(name = "세션 API", description = "세션 관련 API") +class SessionController( + private val sessionFacade: SessionFacade +) { + @GetMapping("/absence") + @Operation( + summary = "불참사유서 제출 페이지에서 세션 정보 조회", + description = "불참사유서 제출 페이지에서 세션 정보를 조회합니다." + ) + fun showSessionAboutAbsence( + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "5") size: Int + ) : ResponseEntity>> { + val safePage = if (page < 1) 0 else page - 1 + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showSessionAboutAbsence(safePage, size))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt b/src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt new file mode 100644 index 0000000..52bea66 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.session.dto + +data class SessionAboutAbsenceResponse( + val sessionId : Long?, + val title : String, + val week : Long, + val active : Boolean +) 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..192629d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -0,0 +1,18 @@ +package onku.backend.domain.session.facade + +import onku.backend.domain.session.dto.SessionAboutAbsenceResponse +import onku.backend.domain.session.service.SessionService +import onku.backend.global.page.PageResponse +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Component + +@Component +class SessionFacade( + private val sessionService: SessionService +) { + fun showSessionAboutAbsence(page: Int, size: Int): PageResponse { + val pageRequest = PageRequest.of(page, size) + val sessionPage = sessionService.getUpcomingSessionsForAbsence(pageRequest) + return PageResponse.from(sessionPage) + } +} \ 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 index 42f997a..28cf23c 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -1,6 +1,9 @@ package onku.backend.domain.session.repository import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.projection.SessionAboutAbsenceProjection +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.query.Param @@ -20,4 +23,14 @@ interface SessionRepository : CrudRepository { nativeQuery = true ) fun findOpenSession(@Param("now") now: LocalDateTime): Session? + + @Query(""" + SELECT s.id AS id, + s.title AS title, + s.week AS week, + s.startTime AS startTime + FROM Session s + WHERE s.startTime >= :now + """) + fun findUpcomingSessions(@Param("now") now: LocalDateTime, pageable: Pageable): Page } diff --git a/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt b/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt new file mode 100644 index 0000000..bc7fdb4 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.session.repository.projection + +import java.time.LocalDateTime + +interface SessionAboutAbsenceProjection { + val id: Long? + val title: String + val week: Long + val startTime: LocalDateTime +} \ 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..4255775 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -0,0 +1,41 @@ +package onku.backend.domain.session.service + +import onku.backend.domain.session.dto.SessionAboutAbsenceResponse +import onku.backend.domain.session.repository.SessionRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import java.time.DayOfWeek +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.temporal.TemporalAdjusters + +@Service +class SessionService( + private val sessionRepository: SessionRepository +) { + fun getUpcomingSessionsForAbsence(pageable: Pageable): Page { + val zone = ZoneId.of("Asia/Seoul") + val now = LocalDateTime.now(zone) + val today = now.toLocalDate() + + val isFriOrSat = today.dayOfWeek == DayOfWeek.FRIDAY || today.dayOfWeek == DayOfWeek.SATURDAY + val upcomingSaturday = if (isFriOrSat) + today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) + else null + + val sessions = sessionRepository.findUpcomingSessions(now, pageable) + + return sessions.map { s -> + val sessionDate = s.startTime.atZone(zone).toLocalDate() + val active = !(isFriOrSat && upcomingSaturday != null && sessionDate == upcomingSaturday) + + SessionAboutAbsenceResponse( + sessionId = s.id, + title = s.title, + week = s.week, + active = active + ) + } + } +} \ No newline at end of file From fc036bdca5e2295a5e07085b40689a1f5e1e6a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 02:41:21 +0900 Subject: [PATCH 135/470] =?UTF-8?q?feat=20:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=EC=A0=9C=EC=B6=9C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/absence/AbsenceReport.kt | 57 +++++++++++++++++++ .../absence/controller/AbsenceController.kt | 27 +++++++++ .../dto/request/SubmitAbsenceReportRequest.kt | 12 ++++ .../absence/enums/AbsenceReportApproval.kt | 5 ++ .../domain/absence/enums/AbsenceType.kt | 5 ++ .../domain/absence/facade/AbsenceFacade.kt | 25 ++++++++ .../repository/AbsenceReportRepository.kt | 7 +++ .../domain/absence/service/AbsenceService.kt | 26 +++++++++ .../domain/session/service/SessionService.kt | 11 ++++ .../backend/global/exception/ErrorCode.kt | 3 +- .../backend/global/s3/enums/FolderName.kt | 3 +- 11 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/enums/AbsenceReportApproval.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/enums/AbsenceType.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt 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..dfc7d43 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt @@ -0,0 +1,57 @@ +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.AbsenceType +import onku.backend.domain.member.Member +import onku.backend.domain.session.Session +import onku.backend.global.entity.BaseEntity + +@Entity +class AbsenceReport( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "absence_report_id") + val id: Long? = null, + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id") + val session : Session, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + val member : Member, + + @Column(name = "url") + val url : String, + + @Column(name = "status") + @Enumerated(EnumType.STRING) + val status : AbsenceType, + + @Column(name = "reason") + val reason : String, + + @Column(name = "approval") + @Enumerated(EnumType.STRING) + val approval : AbsenceReportApproval, +) : BaseEntity() { + companion object { + fun createAbsenceReport( + member: Member, + session : Session, + submitAbsenceReportRequest: SubmitAbsenceReportRequest, + fileKey : String, + ): AbsenceReport { + return AbsenceReport( + member = member, + session = session, + url = fileKey, + status = submitAbsenceReportRequest.absenceType, + reason = submitAbsenceReportRequest.reason, + approval = AbsenceReportApproval.SUBMIT + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt new file mode 100644 index 0000000..a8060e9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt @@ -0,0 +1,27 @@ +package onku.backend.domain.absence.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +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.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/absence") +@Tag(name = "불참사유서 관련 API") +class AbsenceController( + private val absenceFacade : AbsenceFacade +) { + @PostMapping("") + @Operation(summary = "불참 사유서 제출", description = "불참 사유서 제출하는 API 입니다") + fun submitAttendanceReport(@CurrentMember member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.submitAbsenceReport(member, submitAbsenceReportRequest))) + } +} \ No newline at end of file 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..ba6b362 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt @@ -0,0 +1,12 @@ +package onku.backend.domain.absence.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import onku.backend.domain.absence.enums.AbsenceType + +data class SubmitAbsenceReportRequest( + @field:NotNull val sessionId : Long, + val absenceType : AbsenceType, + val reason : String, + @field:NotBlank val fileName : String +) \ 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/AbsenceType.kt b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceType.kt new file mode 100644 index 0000000..b55e77d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceType.kt @@ -0,0 +1,5 @@ +package onku.backend.domain.absence.enums + +enum class AbsenceType { + 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..94986ea --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -0,0 +1,25 @@ +package onku.backend.domain.absence.facade + +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.service.AbsenceService +import onku.backend.domain.member.Member +import onku.backend.domain.session.service.SessionService +import onku.backend.global.s3.dto.GetPreSignedUrlDto +import onku.backend.global.s3.enums.FolderName +import onku.backend.global.s3.service.S3Service +import org.springframework.stereotype.Component + +@Component +class AbsenceFacade( + private val absenceService : AbsenceService, + private val s3Service: S3Service, + private val sessionService: SessionService +) { + fun submitAbsenceReport(member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): GetPreSignedUrlDto { + val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name) + val session = sessionService.getById(submitAbsenceReportRequest.sessionId) + absenceService.submitAbsenceReport(member, submitAbsenceReportRequest, preSignedUrlDto.key, session) + return GetPreSignedUrlDto(preSignedUrlDto.preSignedUrl) + } + +} \ 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..61ff905 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -0,0 +1,7 @@ +package onku.backend.domain.absence.repository + +import onku.backend.domain.absence.AbsenceReport +import org.springframework.data.jpa.repository.JpaRepository + +interface AbsenceReportRepository : JpaRepository { +} \ 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..10e2cdf --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -0,0 +1,26 @@ +package onku.backend.domain.absence.service + +import onku.backend.domain.absence.AbsenceReport +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.repository.AbsenceReportRepository +import onku.backend.domain.member.Member +import onku.backend.domain.session.Session +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) { + absenceReportRepository.save( + AbsenceReport.createAbsenceReport( + member = member, + session = session, + submitAbsenceReportRequest, + fileKey + ) + ) + } +} \ 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 index 4255775..185879c 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -1,10 +1,15 @@ package onku.backend.domain.session.service +import onku.backend.domain.session.Session import onku.backend.domain.session.dto.SessionAboutAbsenceResponse import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode 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.DayOfWeek import java.time.LocalDateTime import java.time.ZoneId @@ -14,6 +19,7 @@ import java.time.temporal.TemporalAdjusters class SessionService( private val sessionRepository: SessionRepository ) { + @Transactional(readOnly = true) fun getUpcomingSessionsForAbsence(pageable: Pageable): Page { val zone = ZoneId.of("Asia/Seoul") val now = LocalDateTime.now(zone) @@ -38,4 +44,9 @@ class SessionService( ) } } + + @Transactional(readOnly = true) + fun getById(id : Long) : Session { + return sessionRepository.findByIdOrNull(id) ?: throw CustomException(ErrorCode.SESSION_NOT_FOUND) + } } \ 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 index 0963286..751a2b4 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -17,5 +17,6 @@ enum class ErrorCode( INVALID_FILE_EXTENSION("S3001", "올바르지 않은 파일 확장자 입니다.", HttpStatus.BAD_REQUEST), KUPICK_APPLICATION_FIRST("kupick001", "큐픽 신청부터 진행해주세요", HttpStatus.BAD_REQUEST), KUPICK_NOT_FOUND("kupick002", "해당 큐픽 객체를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST); + FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST), + SESSION_NOT_FOUND("session001", "해당 세션이 존재하지 않습니다.", HttpStatus.NOT_FOUND); } diff --git a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt index a033d48..f919f4f 100644 --- a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt +++ b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt @@ -2,5 +2,6 @@ package onku.backend.global.s3.enums enum class FolderName{ KUPICK_APPLICATION, - KUPICK_VIEW; + KUPICK_VIEW, + ABSENCE } \ No newline at end of file From c000e557a2d215d0183d7f958dcf7e3520b51dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 19:30:24 +0900 Subject: [PATCH 136/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20upsert=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/absence/AbsenceReport.kt | 30 ++++++++++++++++--- .../absence/controller/AbsenceController.kt | 4 ++- .../dto/request/SubmitAbsenceReportRequest.kt | 1 + .../domain/absence/facade/AbsenceFacade.kt | 16 ++++++++-- .../domain/absence/service/AbsenceService.kt | 15 ++++++++-- .../absence/validator/AbsenceValidator.kt | 30 +++++++++++++++++++ .../session/repository/SessionRepository.kt | 3 +- .../SessionAboutAbsenceProjection.kt | 10 ------- .../domain/session/service/SessionService.kt | 21 +++++-------- 9 files changed, 94 insertions(+), 36 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt delete mode 100644 src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt diff --git a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt index dfc7d43..39587e4 100644 --- a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt +++ b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt @@ -7,8 +7,18 @@ import onku.backend.domain.absence.enums.AbsenceType 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) @@ -17,21 +27,21 @@ class AbsenceReport( @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "session_id") - val session : Session, + var session : Session, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") val member : Member, @Column(name = "url") - val url : String, + var url : String, @Column(name = "status") @Enumerated(EnumType.STRING) - val status : AbsenceType, + var status : AbsenceType, @Column(name = "reason") - val reason : String, + var reason : String, @Column(name = "approval") @Enumerated(EnumType.STRING) @@ -54,4 +64,16 @@ class AbsenceReport( ) } } + + fun updateAbsenceReport( + submitAbsenceReportRequest: SubmitAbsenceReportRequest, + fileKey: String, + session: Session + ) { + this.session = session + this.reason = submitAbsenceReportRequest.reason + this.url = fileKey + this.status = submitAbsenceReportRequest.absenceType + this.updatedAt = LocalDateTime.now() + } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt index a8060e9..0eacb0d 100644 --- a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt +++ b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt @@ -10,6 +10,7 @@ import onku.backend.global.response.SuccessResponse import onku.backend.global.s3.dto.GetPreSignedUrlDto import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -21,7 +22,8 @@ class AbsenceController( ) { @PostMapping("") @Operation(summary = "불참 사유서 제출", description = "불참 사유서 제출하는 API 입니다") - fun submitAttendanceReport(@CurrentMember member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { + fun submitAttendanceReport(@CurrentMember member: Member, + @RequestBody submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.submitAbsenceReport(member, submitAbsenceReportRequest))) } } \ No newline at end of file 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 index ba6b362..8db6cee 100644 --- a/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt +++ b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotNull import onku.backend.domain.absence.enums.AbsenceType data class SubmitAbsenceReportRequest( + val absenceReportId : Long?, @field:NotNull val sessionId : Long, val absenceType : AbsenceType, val reason : String, diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index 94986ea..08e9f0f 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -2,8 +2,11 @@ package onku.backend.domain.absence.facade import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest import onku.backend.domain.absence.service.AbsenceService +import onku.backend.domain.absence.validator.AbsenceValidator import onku.backend.domain.member.Member import onku.backend.domain.session.service.SessionService +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode import onku.backend.global.s3.dto.GetPreSignedUrlDto import onku.backend.global.s3.enums.FolderName import onku.backend.global.s3.service.S3Service @@ -13,11 +16,20 @@ import org.springframework.stereotype.Component class AbsenceFacade( private val absenceService : AbsenceService, private val s3Service: S3Service, - private val sessionService: SessionService + private val sessionService: SessionService, + private val absenceValidator: AbsenceValidator ) { fun submitAbsenceReport(member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): GetPreSignedUrlDto { - val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name) val session = sessionService.getById(submitAbsenceReportRequest.sessionId) + when { + absenceValidator.isPastSession(session) -> { + throw CustomException(ErrorCode.SESSION_PAST) + } + absenceValidator.isImminentSession(session) -> { + throw CustomException(ErrorCode.SESSION_IMMINENT) + } + } + val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name) absenceService.submitAbsenceReport(member, submitAbsenceReportRequest, preSignedUrlDto.key, session) return GetPreSignedUrlDto(preSignedUrlDto.preSignedUrl) } diff --git a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt index 10e2cdf..b0a053f 100644 --- a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -5,22 +5,31 @@ import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest import onku.backend.domain.absence.repository.AbsenceReportRepository import onku.backend.domain.member.Member import onku.backend.domain.session.Session +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service class AbsenceService( - private val absenceReportRepository: AbsenceReportRepository + private val absenceReportRepository: AbsenceReportRepository, + ) { @Transactional fun submitAbsenceReport(member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest, fileKey : String, session : Session) { - absenceReportRepository.save( + 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, fileKey ) - ) + } + absenceReportRepository.save(report) } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt b/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt new file mode 100644 index 0000000..be258c1 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt @@ -0,0 +1,30 @@ +package onku.backend.domain.absence.validator + +import onku.backend.domain.session.Session +import org.springframework.stereotype.Component +import java.time.DayOfWeek +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.temporal.TemporalAdjusters + +@Component +class AbsenceValidator { + + private val zone: ZoneId = ZoneId.of("Asia/Seoul") + + /** 지난 세션 여부 */ + fun isPastSession(session: Session, now: LocalDateTime = LocalDateTime.now(zone)): Boolean { + return session.endTime.isBefore(now) + } + + /** 금/토에 바로 앞 토요일 세션인지 여부 */ + fun isImminentSession(session: Session, now: LocalDateTime = LocalDateTime.now(zone)): Boolean { + val today = now.toLocalDate() + val isFriOrSat = today.dayOfWeek == DayOfWeek.FRIDAY || today.dayOfWeek == DayOfWeek.SATURDAY + if (!isFriOrSat) return false + + val upcomingSaturday = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) + val sessionDate = session.startTime.atZone(zone).toLocalDate() + return sessionDate == upcomingSaturday + } +} \ 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 index 28cf23c..117fea5 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -1,7 +1,6 @@ package onku.backend.domain.session.repository import onku.backend.domain.session.Session -import onku.backend.domain.session.repository.projection.SessionAboutAbsenceProjection import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.Query @@ -32,5 +31,5 @@ interface SessionRepository : CrudRepository { FROM Session s WHERE s.startTime >= :now """) - fun findUpcomingSessions(@Param("now") now: LocalDateTime, pageable: Pageable): Page + fun findUpcomingSessions(@Param("now") now: LocalDateTime, pageable: Pageable): Page } diff --git a/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt b/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt deleted file mode 100644 index bc7fdb4..0000000 --- a/src/main/kotlin/onku/backend/domain/session/repository/projection/SessionAboutAbsenceProjection.kt +++ /dev/null @@ -1,10 +0,0 @@ -package onku.backend.domain.session.repository.projection - -import java.time.LocalDateTime - -interface SessionAboutAbsenceProjection { - val id: Long? - val title: String - val week: Long - val startTime: LocalDateTime -} \ 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 index 185879c..c3b571d 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -1,5 +1,6 @@ package onku.backend.domain.session.service +import onku.backend.domain.absence.validator.AbsenceValidator import onku.backend.domain.session.Session import onku.backend.domain.session.dto.SessionAboutAbsenceResponse import onku.backend.domain.session.repository.SessionRepository @@ -10,32 +11,24 @@ 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.DayOfWeek +import java.time.Clock import java.time.LocalDateTime import java.time.ZoneId -import java.time.temporal.TemporalAdjusters @Service class SessionService( - private val sessionRepository: SessionRepository + private val sessionRepository: SessionRepository, + private val absenceValidator: AbsenceValidator, + private val clock: Clock = Clock.system(ZoneId.of("Asia/Seoul")) ) { @Transactional(readOnly = true) fun getUpcomingSessionsForAbsence(pageable: Pageable): Page { - val zone = ZoneId.of("Asia/Seoul") - val now = LocalDateTime.now(zone) - val today = now.toLocalDate() - - val isFriOrSat = today.dayOfWeek == DayOfWeek.FRIDAY || today.dayOfWeek == DayOfWeek.SATURDAY - val upcomingSaturday = if (isFriOrSat) - today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) - else null + val now = LocalDateTime.now(clock) val sessions = sessionRepository.findUpcomingSessions(now, pageable) return sessions.map { s -> - val sessionDate = s.startTime.atZone(zone).toLocalDate() - val active = !(isFriOrSat && upcomingSaturday != null && sessionDate == upcomingSaturday) - + val active = absenceValidator.isImminentSession(s, now) SessionAboutAbsenceResponse( sessionId = s.id, title = s.title, From dae64b67183c64a6869715583dcb04d30d8171f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 19:30:44 +0900 Subject: [PATCH 137/470] =?UTF-8?q?feat=20:=20=EB=AC=B4=EA=B2=B0=EC=84=B1?= =?UTF-8?q?=20=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=20DB=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=ED=95=B8=EB=93=A4=EB=A7=81=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/exception/ErrorCode.kt | 5 ++++- .../backend/global/exception/ExceptionAdvice.kt | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt index 751a2b4..39e44a0 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -18,5 +18,8 @@ enum class ErrorCode( KUPICK_APPLICATION_FIRST("kupick001", "큐픽 신청부터 진행해주세요", HttpStatus.BAD_REQUEST), KUPICK_NOT_FOUND("kupick002", "해당 큐픽 객체를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST), - SESSION_NOT_FOUND("session001", "해당 세션이 존재하지 않습니다.", HttpStatus.NOT_FOUND); + SESSION_NOT_FOUND("session001", "해당 세션이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + SESSION_PAST("session002", "이미 지난 세션입니다.", HttpStatus.BAD_REQUEST), + SESSION_IMMINENT("session003", "불참제출서 제출은 목요일까지입니다.", 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 index 4edefa9..b65d762 100644 --- a/src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt +++ b/src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt @@ -119,4 +119,17 @@ class ExceptionAdvice { 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) + } + } From b541b5585601e95927079a335ad9545b111a9b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 19:55:15 +0900 Subject: [PATCH 138/470] =?UTF-8?q?feat=20:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=EC=A0=9C=EC=B6=9C=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../absence/controller/AbsenceController.kt | 20 ++++++++++++----- .../response/GetMyAbsenceReportResponse.kt | 14 ++++++++++++ .../domain/absence/facade/AbsenceFacade.kt | 19 ++++++++++++++++ .../repository/AbsenceReportRepository.kt | 22 +++++++++++++++++++ .../projection/GetMyAbsenceReportView.kt | 14 ++++++++++++ .../domain/absence/service/AbsenceService.kt | 18 +++++++++++++++ .../attendance/service/AttendanceService.kt | 1 - 7 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/absence/dto/response/GetMyAbsenceReportResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/repository/projection/GetMyAbsenceReportView.kt diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt index 0eacb0d..b6e0944 100644 --- a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt +++ b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt @@ -3,16 +3,15 @@ package onku.backend.domain.absence.controller import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag 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.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.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/absence") @@ -22,8 +21,17 @@ class AbsenceController( ) { @PostMapping("") @Operation(summary = "불참 사유서 제출", description = "불참 사유서 제출하는 API 입니다") - fun submitAttendanceReport(@CurrentMember member: Member, - @RequestBody submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { + fun submitAbsenceReport(@CurrentMember member: Member, + @RequestBody submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.submitAbsenceReport(member, submitAbsenceReportRequest))) } + + @GetMapping("") + @Operation(summary = "불참 사유서 제출내역 조회", description = "내가 낸 불참 사유서 제출내역을 조회합니다.") + fun getMyAbsenceReport(@CurrentMember member: Member, + @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(absenceFacade.getMyAbsenceReport(member, safePage, size))) + } } \ No newline at end of file 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..d78e155 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMyAbsenceReportResponse.kt @@ -0,0 +1,14 @@ +package onku.backend.domain.absence.dto.response + +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.absence.enums.AbsenceType +import java.time.LocalDateTime + +data class GetMyAbsenceReportResponse( + val absenceReportId : Long, + val absenceType : AbsenceType, + val absenceReportApproval : AbsenceReportApproval, + val submitDateTime : LocalDateTime, + val sessionTitle : String, + val sessionStartDateTime: LocalDateTime +) diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index 08e9f0f..7b00fe7 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -1,15 +1,18 @@ package onku.backend.domain.absence.facade import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.dto.response.GetMyAbsenceReportResponse import onku.backend.domain.absence.service.AbsenceService import onku.backend.domain.absence.validator.AbsenceValidator import onku.backend.domain.member.Member import onku.backend.domain.session.service.SessionService import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode +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.service.S3Service +import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Component @Component @@ -34,4 +37,20 @@ class AbsenceFacade( return GetPreSignedUrlDto(preSignedUrlDto.preSignedUrl) } + fun getMyAbsenceReport(member: Member, page: Int, size: Int): PageResponse { + val pageRequest = PageRequest.of(page, size) + val absenceReportPage = absenceService.getMyAbsenceReports(member, pageRequest) + val responses = absenceReportPage.map { v -> + GetMyAbsenceReportResponse( + absenceReportId = v.absenceReportId, + absenceType = v.absenceType, + absenceReportApproval = v.absenceReportApproval, + submitDateTime = v.submitDateTime, + sessionTitle = v.sessionTitle, + sessionStartDateTime = v.sessionStartDateTime + ) + } + return PageResponse.from(responses) + } + } \ 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 index 61ff905..954201c 100644 --- a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -1,7 +1,29 @@ 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.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 interface AbsenceReportRepository : JpaRepository { + @Query(""" + select + ar.id as absenceReportId, + ar.status as absenceType, + ar.approval as absenceReportApproval, + ar.createdAt as submitDateTime, + s.title as sessionTitle, + s.startTime as sessionStartDateTime + from AbsenceReport ar + join ar.session s + where ar.member = :member + """) + fun findMyAbsenceReports( + @Param("member") member: Member, + pageable: Pageable + ): Page } \ 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..2c5e8d2 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/repository/projection/GetMyAbsenceReportView.kt @@ -0,0 +1,14 @@ +package onku.backend.domain.absence.repository.projection + +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.absence.enums.AbsenceType +import java.time.LocalDateTime + +interface GetMyAbsenceReportView { + fun getAbsenceReportId(): Long + fun getAbsenceType(): AbsenceType + fun getAbsenceReportApproval(): AbsenceReportApproval + fun getSubmitDateTime(): LocalDateTime + fun getSessionTitle(): String + fun getSessionStartDateTime(): LocalDateTime +} \ 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 index b0a053f..26ba326 100644 --- a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -2,9 +2,12 @@ package onku.backend.domain.absence.service import onku.backend.domain.absence.AbsenceReport 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 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 @@ -32,4 +35,19 @@ class AbsenceService( } absenceReportRepository.save(report) } + + @Transactional(readOnly = true) + fun getMyAbsenceReports(member: Member, pageable: Pageable): Page { + val page = absenceReportRepository.findMyAbsenceReports(member, pageable) + return page.map { v -> + GetMyAbsenceReportResponse( + absenceReportId = v.getAbsenceReportId(), + absenceType = v.getAbsenceType(), + absenceReportApproval = v.getAbsenceReportApproval(), + submitDateTime = v.getSubmitDateTime(), + sessionTitle = v.getSessionTitle(), + sessionStartDateTime = v.getSessionStartDateTime() + ) + } + } } \ 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 index 85ed8f9..2b60a2d 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -5,7 +5,6 @@ import onku.backend.domain.attendance.dto.* import onku.backend.domain.attendance.enums.AttendanceStatus import onku.backend.domain.attendance.repository.AttendanceRepository import onku.backend.domain.member.Member -import onku.backend.domain.member.enums.Role import onku.backend.domain.member.repository.MemberProfileRepository import onku.backend.domain.session.repository.SessionRepository import onku.backend.global.exception.CustomException From adb1cdb19484efaf409d876bbc750a52c8ebe2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 17 Oct 2025 20:03:56 +0900 Subject: [PATCH 139/470] =?UTF-8?q?refactor=20:=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EB=AC=B8=20=EB=B3=80=EA=B2=BD(=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=EC=85=98=20=EC=82=AC=EC=9A=A9x)=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/session/repository/SessionRepository.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index 117fea5..1d9b026 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -24,10 +24,7 @@ interface SessionRepository : CrudRepository { fun findOpenSession(@Param("now") now: LocalDateTime): Session? @Query(""" - SELECT s.id AS id, - s.title AS title, - s.week AS week, - s.startTime AS startTime + SELECT s FROM Session s WHERE s.startTime >= :now """) From 6c224302f5ac1adf956d706ad74cdb1be72de567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 18 Oct 2025 18:03:45 +0900 Subject: [PATCH 140/470] =?UTF-8?q?refactor=20:=20=ED=9C=B4=ED=9A=8C=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=EB=8F=84=20=EC=84=B8=EC=85=98=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/repository/SessionRepository.kt | 9 +++++++-- .../backend/domain/session/service/SessionService.kt | 6 +++--- .../validator/SessionValidator.kt} | 10 ++++++++-- .../kotlin/onku/backend/global/exception/ErrorCode.kt | 1 + 4 files changed, 19 insertions(+), 7 deletions(-) rename src/main/kotlin/onku/backend/domain/{absence/validator/AbsenceValidator.kt => session/validator/SessionValidator.kt} (78%) diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index 1d9b026..1ccadaa 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -1,6 +1,7 @@ package onku.backend.domain.session.repository import onku.backend.domain.session.Session +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.Query @@ -26,7 +27,11 @@ interface SessionRepository : CrudRepository { @Query(""" SELECT s FROM Session s - WHERE s.startTime >= :now + WHERE s.startTime >= :now AND s.category <> :restCategory """) - fun findUpcomingSessions(@Param("now") now: LocalDateTime, pageable: Pageable): Page + fun findUpcomingSessions( + @Param("now") now: LocalDateTime, + pageable: Pageable, + @Param("restCategory") restCategory: SessionCategory = SessionCategory.REST + ): Page } diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index c3b571d..539246a 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -1,6 +1,6 @@ package onku.backend.domain.session.service -import onku.backend.domain.absence.validator.AbsenceValidator +import onku.backend.domain.session.validator.SessionValidator import onku.backend.domain.session.Session import onku.backend.domain.session.dto.SessionAboutAbsenceResponse import onku.backend.domain.session.repository.SessionRepository @@ -18,7 +18,7 @@ import java.time.ZoneId @Service class SessionService( private val sessionRepository: SessionRepository, - private val absenceValidator: AbsenceValidator, + private val sessionValidator: SessionValidator, private val clock: Clock = Clock.system(ZoneId.of("Asia/Seoul")) ) { @Transactional(readOnly = true) @@ -28,7 +28,7 @@ class SessionService( val sessions = sessionRepository.findUpcomingSessions(now, pageable) return sessions.map { s -> - val active = absenceValidator.isImminentSession(s, now) + val active = sessionValidator.isImminentSession(s, now) SessionAboutAbsenceResponse( sessionId = s.id, title = s.title, diff --git a/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt b/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt similarity index 78% rename from src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt rename to src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt index be258c1..447d65f 100644 --- a/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceValidator.kt +++ b/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt @@ -1,6 +1,7 @@ -package onku.backend.domain.absence.validator +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 @@ -8,7 +9,7 @@ import java.time.ZoneId import java.time.temporal.TemporalAdjusters @Component -class AbsenceValidator { +class SessionValidator { private val zone: ZoneId = ZoneId.of("Asia/Seoul") @@ -27,4 +28,9 @@ class AbsenceValidator { val sessionDate = session.startTime.atZone(zone).toLocalDate() return sessionDate == upcomingSaturday } + + /** 휴회 세션인지 여부 */ + 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/exception/ErrorCode.kt b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt index 39e44a0..cf04a06 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -21,5 +21,6 @@ enum class ErrorCode( 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), SQL_INTEGRITY_VIOLATION("sql001", "무결성 제약조건을 위반하였습니다.", HttpStatus.BAD_REQUEST); } From 2151e255542a393d8713b4ef5ee1a65b3bc7f6c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 18 Oct 2025 18:05:42 +0900 Subject: [PATCH 141/470] =?UTF-8?q?refactor=20:=20=EB=B6=88=EC=B0=B8?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EC=84=9C=20=EC=A0=9C=EC=B6=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A7=80=EA=B0=81=EC=8B=9C=EA=B0=84=EA=B3=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=87=B4=EC=8B=9C=EA=B0=84=EB=8F=84=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EB=B0=9B=EA=B2=8C=20=EA=B5=AC=ED=98=84=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/absence/AbsenceReport.kt | 12 +++- .../absence/controller/AbsenceController.kt | 3 +- .../dto/annotation/ValidAbsenceReport.kt | 15 +++++ .../dto/request/SubmitAbsenceReportRequest.kt | 8 ++- .../domain/absence/facade/AbsenceFacade.kt | 12 ++-- .../validator/AbsenceReportValidator.kt | 55 +++++++++++++++++++ 6 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/absence/dto/annotation/ValidAbsenceReport.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/validator/AbsenceReportValidator.kt diff --git a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt index 39587e4..590d0b8 100644 --- a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt +++ b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt @@ -46,6 +46,12 @@ class AbsenceReport( @Column(name = "approval") @Enumerated(EnumType.STRING) val approval : AbsenceReportApproval, + + @Column(name = "lateDateTime") + var lateDateTime: LocalDateTime?, + + @Column(name = "leaveDateTime") + var leaveDateTime: LocalDateTime? ) : BaseEntity() { companion object { fun createAbsenceReport( @@ -60,7 +66,9 @@ class AbsenceReport( url = fileKey, status = submitAbsenceReportRequest.absenceType, reason = submitAbsenceReportRequest.reason, - approval = AbsenceReportApproval.SUBMIT + approval = AbsenceReportApproval.SUBMIT, + leaveDateTime = submitAbsenceReportRequest.leaveDateTime, + lateDateTime = submitAbsenceReportRequest.lateDateTime ) } } @@ -75,5 +83,7 @@ class AbsenceReport( this.url = fileKey this.status = submitAbsenceReportRequest.absenceType this.updatedAt = LocalDateTime.now() + this.leaveDateTime = submitAbsenceReportRequest.leaveDateTime + this.lateDateTime = submitAbsenceReportRequest.lateDateTime } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt index b6e0944..f829e1a 100644 --- a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt +++ b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt @@ -2,6 +2,7 @@ package onku.backend.domain.absence.controller 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 @@ -22,7 +23,7 @@ class AbsenceController( @PostMapping("") @Operation(summary = "불참 사유서 제출", description = "불참 사유서 제출하는 API 입니다") fun submitAbsenceReport(@CurrentMember member: Member, - @RequestBody submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { + @RequestBody @Valid submitAbsenceReportRequest: SubmitAbsenceReportRequest): ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(absenceFacade.submitAbsenceReport(member, submitAbsenceReportRequest))) } 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/SubmitAbsenceReportRequest.kt b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt index 8db6cee..260b616 100644 --- a/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt +++ b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt @@ -2,12 +2,16 @@ package onku.backend.domain.absence.dto.request import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull +import onku.backend.domain.absence.dto.annotation.ValidAbsenceReport import onku.backend.domain.absence.enums.AbsenceType - +import java.time.LocalDateTime +@ValidAbsenceReport data class SubmitAbsenceReportRequest( val absenceReportId : Long?, @field:NotNull val sessionId : Long, val absenceType : AbsenceType, val reason : String, - @field:NotBlank val fileName : String + @field:NotBlank 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/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index 7b00fe7..bb35979 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -3,7 +3,7 @@ package onku.backend.domain.absence.facade import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest import onku.backend.domain.absence.dto.response.GetMyAbsenceReportResponse import onku.backend.domain.absence.service.AbsenceService -import onku.backend.domain.absence.validator.AbsenceValidator +import onku.backend.domain.session.validator.SessionValidator import onku.backend.domain.member.Member import onku.backend.domain.session.service.SessionService import onku.backend.global.exception.CustomException @@ -20,17 +20,21 @@ class AbsenceFacade( private val absenceService : AbsenceService, private val s3Service: S3Service, private val sessionService: SessionService, - private val absenceValidator: AbsenceValidator + private val sessionValidator: SessionValidator ) { fun submitAbsenceReport(member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): GetPreSignedUrlDto { val session = sessionService.getById(submitAbsenceReportRequest.sessionId) + //세션 검증 when { - absenceValidator.isPastSession(session) -> { + sessionValidator.isPastSession(session) -> { throw CustomException(ErrorCode.SESSION_PAST) } - absenceValidator.isImminentSession(session) -> { + sessionValidator.isImminentSession(session) -> { throw CustomException(ErrorCode.SESSION_IMMINENT) } + sessionValidator.isRestSession(session) -> { + throw CustomException(ErrorCode.INVALID_SESSION) + } } val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name) absenceService.submitAbsenceReport(member, submitAbsenceReportRequest, preSignedUrlDto.key, session) 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..e2512dd --- /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.AbsenceType + +class AbsenceReportValidator : ConstraintValidator { + override fun isValid( + value: SubmitAbsenceReportRequest?, + context: ConstraintValidatorContext + ): Boolean { + if (value == null) return true + + val type = value.absenceType + val late = value.lateDateTime + val leave = value.leaveDateTime + + // 공통적으로 여러 에러메시지를 누적 가능하게 설정 + context.disableDefaultConstraintViolation() + + return when (type) { + AbsenceType.LATE -> { + if (leave != null) { + context.buildConstraintViolationWithTemplate("지각일 경우 leaveDateTime은 비워야 합니다.") + .addPropertyNode("leaveDateTime").addConstraintViolation() + false + } else true + } + AbsenceType.EARLY_LEAVE -> { + if (late != null) { + context.buildConstraintViolationWithTemplate("조퇴일 경우 lateDateTime은 비워야 합니다.") + .addPropertyNode("lateDateTime").addConstraintViolation() + false + } else true + } + AbsenceType.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 From fe3474036fbd727c462008375e9fc59a7ef9a9a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 18 Oct 2025 18:06:54 +0900 Subject: [PATCH 142/470] =?UTF-8?q?chore=20:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=EC=97=90=EB=9F=AC=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/exception/ErrorCode.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt index cf04a06..1f7459d 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -20,7 +20,7 @@ enum class ErrorCode( FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST), SESSION_NOT_FOUND("session001", "해당 세션이 존재하지 않습니다.", HttpStatus.NOT_FOUND), SESSION_PAST("session002", "이미 지난 세션입니다.", HttpStatus.BAD_REQUEST), - SESSION_IMMINENT("session003", "불참제출서 제출은 목요일까지입니다.", HttpStatus.BAD_REQUEST), + SESSION_IMMINENT("session003", "불참사유서 제출은 목요일까지입니다.", HttpStatus.BAD_REQUEST), INVALID_SESSION("session004", "유효한 세션이 아닙니다.", HttpStatus.BAD_REQUEST), SQL_INTEGRITY_VIOLATION("sql001", "무결성 제약조건을 위반하였습니다.", HttpStatus.BAD_REQUEST); } From 3b6c5cbd36283f22da1834528bc85b19dff92b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 16:21:10 +0900 Subject: [PATCH 143/470] =?UTF-8?q?feat:=20Member=EC=97=90=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=EC=A7=84=20=EC=97=AC=EB=B6=80,=20TF=ED=8C=80=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=EC=97=AC=EB=B6=80=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/member/Member.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/member/Member.kt b/src/main/kotlin/onku/backend/domain/member/Member.kt index ff7c809..c41e588 100644 --- a/src/main/kotlin/onku/backend/domain/member/Member.kt +++ b/src/main/kotlin/onku/backend/domain/member/Member.kt @@ -31,7 +31,13 @@ class Member( @Enumerated(EnumType.STRING) @Column(name = "approval", nullable = false, length = 20) - var approval: ApprovalStatus = ApprovalStatus.PENDING + 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 ) : BaseEntity() { fun approve() { this.approval = ApprovalStatus.APPROVED From 38adce52bc9a9e0c90f8ddf65a8d4511e9fa8797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 16:25:25 +0900 Subject: [PATCH 144/470] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84?= =?UTF-8?q?=EC=9D=B4=20=EC=88=98=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=98=EB=8A=94=20=EC=8A=A4=ED=84=B0=EB=94=94,=20?= =?UTF-8?q?=ED=81=90=ED=8F=AC=ED=84=B0=EC=A6=88=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=A0=90=EC=88=98=20=EA=B4=80=EB=A6=AC=EC=9A=A9=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=B6=94=EA=B0=80=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/point/ManualPointRecord.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/ManualPointRecord.kt diff --git a/src/main/kotlin/onku/backend/domain/point/ManualPointRecord.kt b/src/main/kotlin/onku/backend/domain/point/ManualPointRecord.kt new file mode 100644 index 0000000..9e23d02 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/ManualPointRecord.kt @@ -0,0 +1,25 @@ +package onku.backend.domain.point + +import jakarta.persistence.* +import onku.backend.domain.member.Member + +@Entity +class ManualPointRecord( + @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 +) From 69cca7388d36e5204461e6bb11b02ef0527783bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 19:56:48 +0900 Subject: [PATCH 145/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A2=85=EB=A5=98=20enum=20=EA=B0=92=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=AC=B8=EC=84=9C=ED=99=94=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/enums/AttendanceStatus.kt | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt index a161cd9..52ad920 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt @@ -2,12 +2,24 @@ package onku.backend.domain.attendance.enums import io.swagger.v3.oas.annotations.media.Schema -@Schema( - description = "출석 상태", - example = "PRESENT" -) +@Schema(description = "출석 상태") enum class AttendanceStatus { - @Schema(description = "출석(정시)") PRESENT, - @Schema(description = "결석") ABSENT, - @Schema(description = "지각") LATE -} \ No newline at end of file + + @Schema(description = "출석(공휴일 세션): +1점") + PRESENT_HOLIDAY, + + @Schema(description = "출석(정시): 0점") + PRESENT, + + @Schema(description = "사유서 인정(공결): 0점") + EXCUSED, + + @Schema(description = "결석(미제출): -3점") + ABSENT, + + @Schema(description = "결석(사유서 제출): -2점") + ABSENT_WITH_DOC, + + @Schema(description = "지각: -1점") + LATE +} From da8d2412d75741fabb66df1e4e096861a338dfb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 19:58:06 +0900 Subject: [PATCH 146/470] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=EC=9D=98=20Members=20=EC=B6=9C=EC=84=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/repository/AttendanceRepository.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt index 602b597..3bcbe9b 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt @@ -29,4 +29,10 @@ interface AttendanceRepository : CrudRepository { @Param("createdAt") createdAt: LocalDateTime, @Param("updatedAt") updatedAt: LocalDateTime ): Int + + fun findByMemberIdInAndAttendanceTimeBetween( + memberIds: Collection, + start: LocalDateTime, + end: LocalDateTime + ): List } From 4f045e41627104f014b9a86d3876dfaefbd9f3a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 19:58:42 +0900 Subject: [PATCH 147/470] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=EC=9D=98=20Members=20=ED=81=90=ED=94=BD=20=EC=A0=9C?= =?UTF-8?q?=EC=B6=9C=20=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/kupick/repository/KupickRepository.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt index dece59c..a328a59 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt @@ -58,4 +58,20 @@ interface KupickRepository : JpaRepository { @Param("end") end: LocalDateTime, pageable: Pageable ): Page + + @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> } \ No newline at end of file From 4a0b6852597d971679df35481c838eee9900f19c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 19:59:49 +0900 Subject: [PATCH 148/470] =?UTF-8?q?feat:=20Member=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20Part=EB=A1=9C=20=EA=B7=B8=EB=A3=A8=ED=95=91=20?= =?UTF-8?q?=ED=9B=84=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/repository/MemberProfileRepository.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt index 27975d8..cad3585 100644 --- a/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt @@ -6,10 +6,17 @@ 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 } From bb41035984363a6593ca0e72510e2979dbf00414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 20:05:57 +0900 Subject: [PATCH 149/470] =?UTF-8?q?feat:=20=EC=83=81=EB=B2=8C=EC=A0=90=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=EC=9D=84=20=EC=9C=84=ED=95=9C=20dto=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/point/dto/AdminPointsDtos.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/AdminPointsDtos.kt diff --git a/src/main/kotlin/onku/backend/domain/point/dto/AdminPointsDtos.kt b/src/main/kotlin/onku/backend/domain/point/dto/AdminPointsDtos.kt new file mode 100644 index 0000000..4335655 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/AdminPointsDtos.kt @@ -0,0 +1,21 @@ +package onku.backend.domain.point.dto + +import onku.backend.domain.member.enums.Part + +data class AdminPointsRowDto( + 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? +) From 5b6af48571dbf66ca84054ddea3bda0f54f0c2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 20:08:57 +0900 Subject: [PATCH 150/470] =?UTF-8?q?refactor:=20ManualPointRecord=20?= =?UTF-8?q?=E2=86=92=20ManualPoint=20=EB=A1=9C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EB=AA=85=20=EC=A0=95=EC=A0=95=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/point/{ManualPointRecord.kt => ManualPoint.kt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/kotlin/onku/backend/domain/point/{ManualPointRecord.kt => ManualPoint.kt} (95%) diff --git a/src/main/kotlin/onku/backend/domain/point/ManualPointRecord.kt b/src/main/kotlin/onku/backend/domain/point/ManualPoint.kt similarity index 95% rename from src/main/kotlin/onku/backend/domain/point/ManualPointRecord.kt rename to src/main/kotlin/onku/backend/domain/point/ManualPoint.kt index 9e23d02..d911ade 100644 --- a/src/main/kotlin/onku/backend/domain/point/ManualPointRecord.kt +++ b/src/main/kotlin/onku/backend/domain/point/ManualPoint.kt @@ -4,7 +4,7 @@ import jakarta.persistence.* import onku.backend.domain.member.Member @Entity -class ManualPointRecord( +class ManualPoint( @Id @Column(name = "member_id") val memberId: Long? = null, From a79ace0c04c70a711beba8c57a8b472f8080d7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 20:10:31 +0900 Subject: [PATCH 151/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=93=A4=EC=9D=98=20=EC=8A=A4=ED=84=B0=EB=94=94,=20=ED=81=90?= =?UTF-8?q?=ED=8F=AC=ED=84=B0=EC=A6=88=20=ED=98=84=ED=99=A9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/repository/ManualPointRecordRepository.kt | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/repository/ManualPointRecordRepository.kt diff --git a/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRecordRepository.kt b/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRecordRepository.kt new file mode 100644 index 0000000..0d88330 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRecordRepository.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.point.repository + +import onku.backend.domain.point.ManualPoint +import org.springframework.data.jpa.repository.JpaRepository + +interface ManualPointRecordRepository : JpaRepository { + fun findByMemberIdIn(memberIds: Collection): List +} From 36827abe85cf72e50135f5275c7ce1fdef694643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 20:17:52 +0900 Subject: [PATCH 152/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20enum=EC=97=90=20=EA=B0=81=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B3=84=20=EC=B6=9C=EC=84=9D=EC=A0=90=EC=88=98=20point=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/enums/AttendanceStatus.kt | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt index 52ad920..5bc1bc6 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt @@ -3,23 +3,25 @@ package onku.backend.domain.attendance.enums import io.swagger.v3.oas.annotations.media.Schema @Schema(description = "출석 상태") -enum class AttendanceStatus { - +enum class AttendanceStatus( + @Schema(description = "해당 출석 상태의 점수") + val points: Int +) { @Schema(description = "출석(공휴일 세션): +1점") - PRESENT_HOLIDAY, + PRESENT_HOLIDAY(1), @Schema(description = "출석(정시): 0점") - PRESENT, + PRESENT(0), @Schema(description = "사유서 인정(공결): 0점") - EXCUSED, + EXCUSED(0), @Schema(description = "결석(미제출): -3점") - ABSENT, + ABSENT(-3), @Schema(description = "결석(사유서 제출): -2점") - ABSENT_WITH_DOC, + ABSENT_WITH_DOC(-2), @Schema(description = "지각: -1점") - LATE -} + LATE(-1); +} \ No newline at end of file From cdfeb2e54d902255178d609735687a3b6694ff8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 20:25:03 +0900 Subject: [PATCH 153/470] =?UTF-8?q?feat:=20=EC=97=B0=EB=8F=84=EB=B3=84=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=EC=A7=84=20=EC=83=81=EB=B2=8C=EC=A0=90=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=A1=B0=ED=9A=8C(?= =?UTF-8?q?=EC=B6=9C=EA=B2=B0/=ED=81=90=ED=94=BD/=EC=88=98=EB=8F=99?= =?UTF-8?q?=EC=A0=90=EC=88=98=20=EC=A7=91=EA=B3=84)=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/service/AdminPointsService.kt | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt new file mode 100644 index 0000000..6f26e58 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt @@ -0,0 +1,119 @@ +package onku.backend.domain.points.service + +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.kupick.repository.KupickRepository +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.point.dto.AdminPointsRowDto +import onku.backend.domain.point.repository.ManualPointRecordRepository +import onku.backend.global.page.PageResponse +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import kotlin.math.max + +@Service +class AdminPointsService( + private val memberProfileRepository: MemberProfileRepository, + private val attendanceRepository: AttendanceRepository, + private val kupickRepository: KupickRepository, + private val manualPointRecordRepository: ManualPointRecordRepository +) { + + @Transactional(readOnly = true) + fun getAdminOverview( + year: Int, + page: Int, + size: Int + ): PageResponse { + val safePage = max(0, page) + val pageRequest = PageRequest.of(safePage, size) + + val profilePage = memberProfileRepository.findAllByOrderByPartAscNameAsc(pageRequest) + val memberIds = profilePage.content.mapNotNull(MemberProfile::memberId) + if (memberIds.isEmpty()) return PageResponse.from(profilePage.map { emptyRow(year, it) }) + + val startOfYear: LocalDateTime = LocalDateTime.of(year, 1, 1, 0, 0, 0) + val startOfNextYear: LocalDateTime = LocalDateTime.of(year + 1, 1, 1, 0, 0, 0) + + // 출결 집계 (8~12월만) + val attendances = attendanceRepository.findByMemberIdInAndAttendanceTimeBetween( + memberIds, startOfYear, startOfNextYear + ) + val monthlyAttendanceTotals: MutableMap> = mutableMapOf() + attendances.forEach { a -> + val m = a.attendanceTime.month.value + if (m in 8..12) { + val memberId = a.memberId + val map = monthlyAttendanceTotals.getOrPut(memberId) { defaultMonthMapInt() } + map[m] = (map[m] ?: 0) + a.status.points + } + } + + // 큐픽 참여 (8~12월만) + val kupickRows = kupickRepository.findMemberMonthParticipation(memberIds, startOfYear, startOfNextYear) + val kupickMonthMapByMember: MutableMap> = mutableMapOf() + memberIds.forEach { id -> kupickMonthMapByMember[id] = defaultMonthMapBool() } + kupickRows.forEach { row -> + val memberId = (row[0] as Number).toLong() + val m = (row[1] as Number).toInt() + if (m in 8..12) { + kupickMonthMapByMember[memberId]!![m] = true + } + } + + val manualPoints = manualPointRecordRepository.findByMemberIdIn(memberIds) + .associateBy { it.memberId!! } + + val dtoPage = profilePage.map { profile -> + val id = profile.memberId!! + val member = profile.member + val monthTotals = monthlyAttendanceTotals[id] ?: defaultMonthMapInt() + val kupickMap = kupickMonthMapByMember[id] ?: defaultMonthMapBool() + val manual = manualPoints[id] + + AdminPointsRowDto( + memberId = id, + name = profile.name, + part = profile.part, + phoneNumber = profile.phoneNumber, + school = profile.school, + major = profile.major, + isTf = member.isTf, + isStaff = 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 defaultMonthMapInt(): MutableMap = + (8..12).associateWith { 0 }.toMutableMap() + + private fun defaultMonthMapBool(): MutableMap = + (8..12).associateWith { false }.toMutableMap() + + private fun emptyRow(year: Int, profile: onku.backend.domain.member.MemberProfile): AdminPointsRowDto { + return AdminPointsRowDto( + 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 + ) + } +} \ No newline at end of file From 74479fe1c1e8bb7ab2f8e22934b3bb8bb6d80873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 20:26:00 +0900 Subject: [PATCH 154/470] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84=20?= =?UTF-8?q?=EC=83=81=EB=B2=8C=EC=A0=90=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20controller=20=EC=B6=94=EA=B0=80=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/controller/AdminPointsController.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt diff --git a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt new file mode 100644 index 0000000..063e3be --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt @@ -0,0 +1,36 @@ +package onku.backend.domain.points.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import onku.backend.domain.point.dto.AdminPointsRowDto +import onku.backend.domain.points.service.AdminPointsService +import onku.backend.global.page.PageResponse +import onku.backend.global.response.SuccessResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/admin/points") +@Tag( + name = "운영진 상벌점", + description = "운영진 상벌점 대시보드 조회 API" +) +class AdminPointsController( + private val adminPointsService: AdminPointsService +) { + + @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)) + } +} From 8c0be7c5c491d54b79628ddfb7fef7ec4d53a4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 21:54:46 +0900 Subject: [PATCH 155/470] =?UTF-8?q?feat:=20TF,=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94,=20=ED=81=90=ED=8F=AC=ED=84=B0=EC=A6=88,=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EC=88=98=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/point/dto/UpdateIsTfRequest.kt | 8 +++ .../dto/UpdateKupportersPointsRequest.kt | 8 +++ .../domain/point/dto/UpdateMemoRequest.kt | 9 ++++ .../point/dto/UpdateStudyPointsRequest.kt | 8 +++ .../repository/ManualPointRecordRepository.kt | 1 + .../service/AdminPointsCommandService.kt | 52 +++++++++++++++++++ 6 files changed, 86 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateIsTfRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateKupportersPointsRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateMemoRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateStudyPointsRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateIsTfRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateIsTfRequest.kt new file mode 100644 index 0000000..32d810c --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateIsTfRequest.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.point.dto + +import jakarta.validation.constraints.NotNull + +data class UpdateIsTfRequest( + @field:NotNull val memberId: Long?, + @field:NotNull val isTf: Boolean? +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupportersPointsRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupportersPointsRequest.kt new file mode 100644 index 0000000..1ac3326 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupportersPointsRequest.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.point.dto + +import jakarta.validation.constraints.NotNull + +data class UpdateKupportersPointsRequest( + @field:NotNull val memberId: Long?, + @field:NotNull val kuportersPoints: Int? +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateMemoRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateMemoRequest.kt new file mode 100644 index 0000000..b31036d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateMemoRequest.kt @@ -0,0 +1,9 @@ +package onku.backend.domain.point.dto + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull + +data class UpdateMemoRequest( + @field:NotNull val memberId: Long?, + @field:NotBlank val memo: String? +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateStudyPointsRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateStudyPointsRequest.kt new file mode 100644 index 0000000..fe1dae2 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateStudyPointsRequest.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.point.dto + +import jakarta.validation.constraints.NotNull + +data class UpdateStudyPointsRequest( + @field:NotNull val memberId: Long?, + @field:NotNull val studyPoints: Int? +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRecordRepository.kt b/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRecordRepository.kt index 0d88330..14eff61 100644 --- a/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRecordRepository.kt +++ b/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRecordRepository.kt @@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface ManualPointRecordRepository : JpaRepository { fun findByMemberIdIn(memberIds: Collection): List + fun findByMemberId(memberId: Long): ManualPoint? } diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt new file mode 100644 index 0000000..f148812 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt @@ -0,0 +1,52 @@ +package onku.backend.domain.point.service + +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.point.repository.ManualPointRecordRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AdminPointsCommandService( + private val manualPointRecordRepository: ManualPointRecordRepository, + private val memberRepository: MemberRepository +) { + + @Transactional + fun updateStudyPoints(memberId: Long, studyPoints: Int) { + val rec = manualPointRecordRepository.findByMemberId(memberId) + ?: newManualRecord(memberId) + rec.studyPoints = studyPoints + manualPointRecordRepository.save(rec) + } + + @Transactional + fun updateKupportersPoints(memberId: Long, kuportersPoints: Int) { + val rec = manualPointRecordRepository.findByMemberId(memberId) + ?: newManualRecord(memberId) + rec.kupportersPoints = kuportersPoints + manualPointRecordRepository.save(rec) + } + + @Transactional + fun updateMemo(memberId: Long, memo: String) { + val rec = manualPointRecordRepository.findByMemberId(memberId) + ?: newManualRecord(memberId) + rec.memo = memo + manualPointRecordRepository.save(rec) + } + + @Transactional + fun updateIsTf(memberId: Long, isTf: Boolean) { + val member = memberRepository.findById(memberId) + .orElseThrow { IllegalArgumentException("Member not found: $memberId") } + member.isTf = isTf + } + + private fun newManualRecord(memberId: Long) = + onku.backend.domain.point.ManualPoint( + member = memberRepository.getReferenceById(memberId), + studyPoints = 0, + kupportersPoints = 0, + memo = null + ) +} From e0996b38c4f40871c48dc02dd38b82ab53080a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 19 Oct 2025 21:55:05 +0900 Subject: [PATCH 156/470] =?UTF-8?q?feat:=20=EC=8A=A4=ED=84=B0=EB=94=94,=20?= =?UTF-8?q?=ED=81=90=ED=8F=AC=ED=84=B0=EC=A6=88,=20=EB=A9=94=EB=AA=A8,=20T?= =?UTF-8?q?F=20=EC=88=98=EC=A0=95=20controller=20=EC=B6=94=EA=B0=80=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/controller/AdminPointsController.kt | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt index 063e3be..48648b8 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt @@ -2,7 +2,9 @@ package onku.backend.domain.points.controller import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag -import onku.backend.domain.point.dto.AdminPointsRowDto +import jakarta.validation.Valid +import onku.backend.domain.point.dto.* +import onku.backend.domain.point.service.AdminPointsCommandService import onku.backend.domain.points.service.AdminPointsService import onku.backend.global.page.PageResponse import onku.backend.global.response.SuccessResponse @@ -16,7 +18,8 @@ import org.springframework.web.bind.annotation.* description = "운영진 상벌점 대시보드 조회 API" ) class AdminPointsController( - private val adminPointsService: AdminPointsService + private val adminPointsService: AdminPointsService, + private val commandService: AdminPointsCommandService ) { @GetMapping("/overview") @@ -33,4 +36,32 @@ class AdminPointsController( 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> { + commandService.updateStudyPoints(req.memberId!!, req.studyPoints!!) + return ResponseEntity.ok(SuccessResponse.ok(Unit)) + } + + @PatchMapping("/kupporters") + @Operation(summary = "큐포터즈 점수 수정", description = "memberId와 kuportersPoints를 받아 수정합니다.") + fun updateKupportersPoints(@RequestBody @Valid req: UpdateKupportersPointsRequest): ResponseEntity> { + commandService.updateKupportersPoints(req.memberId!!, req.kuportersPoints!!) + return ResponseEntity.ok(SuccessResponse.ok(Unit)) + } + + @PatchMapping("/memo") + @Operation(summary = "메모 수정", description = "memberId와 memo를 받아 수정합니다.") + fun updateMemo(@RequestBody @Valid req: UpdateMemoRequest): ResponseEntity> { + commandService.updateMemo(req.memberId!!, req.memo!!) + return ResponseEntity.ok(SuccessResponse.ok(Unit)) + } + + @PatchMapping("/is-tf") + @Operation(summary = "TF 여부 수정", description = "memberId와 isTf를 받아 Member의 isTf를 수정합니다.") + fun updateIsTf(@RequestBody @Valid req: UpdateIsTfRequest): ResponseEntity> { + commandService.updateIsTf(req.memberId!!, req.isTf!!) + return ResponseEntity.ok(SuccessResponse.ok(Unit)) + } } From 33496a30d8dc38b504c95c8a86911f158f45c44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 20 Oct 2025 18:06:23 +0900 Subject: [PATCH 157/470] =?UTF-8?q?feat:=20TF,=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94,=20=ED=81=90=ED=8F=AC=ED=84=B0=EC=A6=88,=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminPointsCommandService.kt | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt index f148812..ce3fa9b 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt @@ -1,7 +1,10 @@ package onku.backend.domain.point.service +import onku.backend.domain.member.MemberErrorCode import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.point.ManualPoint import onku.backend.domain.point.repository.ManualPointRecordRepository +import onku.backend.global.exception.CustomException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -13,24 +16,21 @@ class AdminPointsCommandService( @Transactional fun updateStudyPoints(memberId: Long, studyPoints: Int) { - val rec = manualPointRecordRepository.findByMemberId(memberId) - ?: newManualRecord(memberId) + val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) rec.studyPoints = studyPoints manualPointRecordRepository.save(rec) } @Transactional fun updateKupportersPoints(memberId: Long, kuportersPoints: Int) { - val rec = manualPointRecordRepository.findByMemberId(memberId) - ?: newManualRecord(memberId) + val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) rec.kupportersPoints = kuportersPoints manualPointRecordRepository.save(rec) } @Transactional fun updateMemo(memberId: Long, memo: String) { - val rec = manualPointRecordRepository.findByMemberId(memberId) - ?: newManualRecord(memberId) + val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) rec.memo = memo manualPointRecordRepository.save(rec) } @@ -38,15 +38,20 @@ class AdminPointsCommandService( @Transactional fun updateIsTf(memberId: Long, isTf: Boolean) { val member = memberRepository.findById(memberId) - .orElseThrow { IllegalArgumentException("Member not found: $memberId") } + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } member.isTf = isTf } - private fun newManualRecord(memberId: Long) = - onku.backend.domain.point.ManualPoint( - member = memberRepository.getReferenceById(memberId), + 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 ) + } } From 1733786108a03485c157dd63b88a9e9358a0b541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 20 Oct 2025 18:31:53 +0900 Subject: [PATCH 158/470] =?UTF-8?q?chore:=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=9C=84=EC=B9=98=20=EC=98=A4=ED=83=88=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/point/controller/AdminPointsController.kt | 4 ++-- .../onku/backend/domain/point/service/AdminPointsService.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt index 48648b8..bb3d672 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt @@ -1,11 +1,11 @@ -package onku.backend.domain.points.controller +package onku.backend.domain.point.controller import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import onku.backend.domain.point.dto.* import onku.backend.domain.point.service.AdminPointsCommandService -import onku.backend.domain.points.service.AdminPointsService +import onku.backend.domain.point.service.AdminPointsService import onku.backend.global.page.PageResponse import onku.backend.global.response.SuccessResponse import org.springframework.http.ResponseEntity diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt index 6f26e58..e5e654c 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.points.service +package onku.backend.domain.point.service import onku.backend.domain.attendance.repository.AttendanceRepository import onku.backend.domain.kupick.repository.KupickRepository From f86faf4b83247dea5d9e77fb894b3b77b354beaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 20 Oct 2025 20:03:18 +0900 Subject: [PATCH 159/470] =?UTF-8?q?chore:=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...anualPointRecordRepository.kt => ManualPointRepository.kt} | 2 +- .../backend/domain/point/service/AdminPointsCommandService.kt | 4 ++-- .../onku/backend/domain/point/service/AdminPointsService.kt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename src/main/kotlin/onku/backend/domain/point/repository/{ManualPointRecordRepository.kt => ManualPointRepository.kt} (78%) diff --git a/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRecordRepository.kt b/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRepository.kt similarity index 78% rename from src/main/kotlin/onku/backend/domain/point/repository/ManualPointRecordRepository.kt rename to src/main/kotlin/onku/backend/domain/point/repository/ManualPointRepository.kt index 14eff61..4135f4e 100644 --- a/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRecordRepository.kt +++ b/src/main/kotlin/onku/backend/domain/point/repository/ManualPointRepository.kt @@ -3,7 +3,7 @@ package onku.backend.domain.point.repository import onku.backend.domain.point.ManualPoint import org.springframework.data.jpa.repository.JpaRepository -interface ManualPointRecordRepository : JpaRepository { +interface ManualPointRepository : JpaRepository { fun findByMemberIdIn(memberIds: Collection): List fun findByMemberId(memberId: Long): ManualPoint? } diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt index ce3fa9b..4825ddb 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt @@ -3,14 +3,14 @@ package onku.backend.domain.point.service import onku.backend.domain.member.MemberErrorCode import onku.backend.domain.member.repository.MemberRepository import onku.backend.domain.point.ManualPoint -import onku.backend.domain.point.repository.ManualPointRecordRepository +import onku.backend.domain.point.repository.ManualPointRepository import onku.backend.global.exception.CustomException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service class AdminPointsCommandService( - private val manualPointRecordRepository: ManualPointRecordRepository, + private val manualPointRecordRepository: ManualPointRepository, private val memberRepository: MemberRepository ) { diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt index e5e654c..cd4aa96 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt @@ -5,7 +5,7 @@ import onku.backend.domain.kupick.repository.KupickRepository import onku.backend.domain.member.MemberProfile import onku.backend.domain.member.repository.MemberProfileRepository import onku.backend.domain.point.dto.AdminPointsRowDto -import onku.backend.domain.point.repository.ManualPointRecordRepository +import onku.backend.domain.point.repository.ManualPointRepository import onku.backend.global.page.PageResponse import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service @@ -18,7 +18,7 @@ class AdminPointsService( private val memberProfileRepository: MemberProfileRepository, private val attendanceRepository: AttendanceRepository, private val kupickRepository: KupickRepository, - private val manualPointRecordRepository: ManualPointRecordRepository + private val manualPointRecordRepository: ManualPointRepository ) { @Transactional(readOnly = true) From 66ac4aa7452e8158c455fda2325a027908c62fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 20 Oct 2025 21:48:28 +0900 Subject: [PATCH 160/470] =?UTF-8?q?feat:=20=EC=9B=94=EB=B3=84=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=ED=98=84=ED=99=A9=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20?= =?UTF-8?q?dto=20=EC=B6=94=EA=B0=80=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/point/dto/AttendanceRecordDto.kt | 11 +++++++++++ .../domain/point/dto/MemberMonthlyAttendanceDto.kt | 7 +++++++ .../domain/point/dto/MonthlyAttendancePageResponse.kt | 10 ++++++++++ 3 files changed, 28 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/AttendanceRecordDto.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/MemberMonthlyAttendanceDto.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/MonthlyAttendancePageResponse.kt diff --git a/src/main/kotlin/onku/backend/domain/point/dto/AttendanceRecordDto.kt b/src/main/kotlin/onku/backend/domain/point/dto/AttendanceRecordDto.kt new file mode 100644 index 0000000..ef2e22e --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/AttendanceRecordDto.kt @@ -0,0 +1,11 @@ +package onku.backend.domain.point.dto + +import onku.backend.domain.attendance.enums.AttendanceStatus +import java.time.LocalDate + +data class AttendanceRecordDto( + val date: LocalDate, + val attendanceId: Long?, + val status: AttendanceStatus?, + val point: Int? +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/MemberMonthlyAttendanceDto.kt b/src/main/kotlin/onku/backend/domain/point/dto/MemberMonthlyAttendanceDto.kt new file mode 100644 index 0000000..c7d086d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/MemberMonthlyAttendanceDto.kt @@ -0,0 +1,7 @@ +package onku.backend.domain.point.dto + +data class MemberMonthlyAttendanceDto( + val memberId: Long, + val name: String, + val records: List +) \ 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..2e39a62 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/MonthlyAttendancePageResponse.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.point.dto + +import onku.backend.global.page.PageResponse + +data class MonthlyAttendancePageResponse( + val year: Int, + val month: Int, + val sessionDates: List, + val members: PageResponse +) From 1c6690e1b60c63ad5d07454d78697f306926670c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 20 Oct 2025 21:49:09 +0900 Subject: [PATCH 161/470] =?UTF-8?q?feat:=20=ED=95=B4=EB=8B=B9=20=EC=9B=94?= =?UTF-8?q?=EC=97=90=20=EC=A7=84=ED=96=89=EB=90=9C=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=9A=A9=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/repository/SessionRepository.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index 1ccadaa..84722e8 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -34,4 +34,14 @@ interface SessionRepository : CrudRepository { pageable: Pageable, @Param("restCategory") restCategory: SessionCategory = SessionCategory.REST ): Page + + @Query(""" + select s.startTime + from Session s + where s.startTime >= :start and s.startTime < :end + """) + fun findStartTimesBetween( + @Param("start") start: LocalDateTime, + @Param("end") end: LocalDateTime + ): List } From 60cb93240d0076ef70f9ed68661da342e4cfb300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 20 Oct 2025 21:49:44 +0900 Subject: [PATCH 162/470] =?UTF-8?q?feat:=20=EC=9B=94=EB=B3=84=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=ED=98=84=ED=99=A9=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/service/AdminPointsService.kt | 126 +++++++++++++++++- 1 file changed, 121 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt index cd4aa96..d5d166f 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt @@ -4,13 +4,14 @@ import onku.backend.domain.attendance.repository.AttendanceRepository import onku.backend.domain.kupick.repository.KupickRepository import onku.backend.domain.member.MemberProfile import onku.backend.domain.member.repository.MemberProfileRepository -import onku.backend.domain.point.dto.AdminPointsRowDto +import onku.backend.domain.point.dto.* import onku.backend.domain.point.repository.ManualPointRepository +import onku.backend.domain.session.repository.SessionRepository import onku.backend.global.page.PageResponse import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.LocalDateTime +import java.time.* import kotlin.math.max @Service @@ -18,7 +19,9 @@ class AdminPointsService( private val memberProfileRepository: MemberProfileRepository, private val attendanceRepository: AttendanceRepository, private val kupickRepository: KupickRepository, - private val manualPointRecordRepository: ManualPointRepository + private val manualPointRecordRepository: ManualPointRepository, + private val sessionRepository: SessionRepository, + private val clock: Clock ) { @Transactional(readOnly = true) @@ -37,7 +40,6 @@ class AdminPointsService( val startOfYear: LocalDateTime = LocalDateTime.of(year, 1, 1, 0, 0, 0) val startOfNextYear: LocalDateTime = LocalDateTime.of(year + 1, 1, 1, 0, 0, 0) - // 출결 집계 (8~12월만) val attendances = attendanceRepository.findByMemberIdInAndAttendanceTimeBetween( memberIds, startOfYear, startOfNextYear ) @@ -51,7 +53,6 @@ class AdminPointsService( } } - // 큐픽 참여 (8~12월만) val kupickRows = kupickRepository.findMemberMonthParticipation(memberIds, startOfYear, startOfNextYear) val kupickMonthMapByMember: MutableMap> = mutableMapOf() memberIds.forEach { id -> kupickMonthMapByMember[id] = defaultMonthMapBool() } @@ -116,4 +117,119 @@ class AdminPointsService( memo = null ) } + + @Transactional(readOnly = true) + fun getMonthlyPaged( + year: Int, + month: Int, + page: Int, + size: Int + ): MonthlyAttendancePageResponse { + require(month in 8..12) { "month must be 8..12" } + + val zone: ZoneId = clock.zone + val startZdt = ZonedDateTime.of(LocalDate.of(year, month, 1), LocalTime.MIN, zone) + val endZdt = startZdt.plusMonths(1) + val start = startZdt.toLocalDateTime() + val end = endZdt.toLocalDateTime() + + val sessionDates: List = sessionRepository.findStartTimesBetween(start, end) + .map { it.toLocalDate() } + .distinct() + .sorted() + val sessionDays: List = sessionDates.map { it.dayOfMonth } + + val pageable = PageRequest.of(page, size) + val memberPage = memberProfileRepository.findAllByOrderByPartAscNameAsc(pageable) + + val pageMemberIds = memberPage.content.mapNotNull { it.memberId } + if (pageMemberIds.isEmpty()) { + return MonthlyAttendancePageResponse( + year = year, + month = month, + sessionDates = sessionDays, + members = PageResponse.from( + memberPage.map { p -> + MemberMonthlyAttendanceDto( + memberId = p.memberId!!, + name = p.name ?: "Unknown", + records = emptyList() + ) + } + ) + ) + } + + val attendances = attendanceRepository + .findByMemberIdInAndAttendanceTimeBetween(pageMemberIds, start, end) + + data class Row( + val memberId: Long, + val date: LocalDate, + val attendanceId: Long?, + val status: onku.backend.domain.attendance.enums.AttendanceStatus?, + val point: Int? + ) + + val rows: List = attendances.map { a -> + Row( + memberId = a.memberId, + date = a.attendanceTime.toLocalDate(), + attendanceId = a.id, + status = a.status, + point = a.status.points + ) + } + + val nameById = memberPage.content.associate { it.memberId!! to (it.name ?: "Unknown") } + val byMember = rows.groupBy { it.memberId } + + val memberDtos = memberPage.content.map { profile -> + val mid = profile.memberId!! + val base = byMember[mid] + ?.sortedBy { it.date } + ?.map { + AttendanceRecordDto( + date = it.date, + attendanceId = it.attendanceId, + status = it.status, + point = it.point + ) + } + ?.toMutableList() + ?: mutableListOf() + + if (sessionDates.isNotEmpty()) { + val recorded = base.map { it.date }.toSet() + sessionDates.filter { it !in recorded }.forEach { d -> + base.add( + AttendanceRecordDto( + date = d, + attendanceId = null, + status = null, + point = null + ) + ) + } + base.sortBy { it.date } + } + + MemberMonthlyAttendanceDto( + memberId = mid, + name = nameById[mid] ?: "Unknown", + records = base + ) + } + + val dtoPage = memberPage.map { p -> + memberDtos.first { it.memberId == p.memberId } + } + + return MonthlyAttendancePageResponse( + year = year, + month = month, + sessionDates = sessionDays, + members = PageResponse.from(dtoPage) + ) + } } \ No newline at end of file From c611764e51dfa5518b99fc0412fd29ccd2020be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 20 Oct 2025 21:50:03 +0900 Subject: [PATCH 163/470] =?UTF-8?q?feat:=20=EC=9B=94=EB=B3=84=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=ED=98=84=ED=99=A9=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20?= =?UTF-8?q?endpoint=20=EC=B6=94=EA=B0=80=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/controller/AdminPointsController.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt index bb3d672..3b3dc7f 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt @@ -64,4 +64,20 @@ class AdminPointsController( commandService.updateIsTf(req.memberId!!, req.isTf!!) return ResponseEntity.ok(SuccessResponse.ok(Unit)) } + + @GetMapping("/monthly") + @Operation( + summary = "월간 출석 현황 [운영진]", + description = "year, month, page, size를 받아 멤버별 [date, attendanceId, status, point] 목록을 페이징으로 반환" + ) + fun getMonthly( + @RequestParam 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)) + } } From 22cf1992203953ff674abffaa64b88da18859a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 20 Oct 2025 23:38:04 +0900 Subject: [PATCH 164/470] =?UTF-8?q?feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/member/MemberErrorCode.kt | 1 + .../point/service/AdminPointsService.kt | 143 +++++++++--------- 2 files changed, 69 insertions(+), 75 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt b/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt index 990dd9d..d5f4763 100644 --- a/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt +++ b/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt @@ -10,4 +10,5 @@ enum class MemberErrorCode( ) : ApiErrorCode { MEMBER_NOT_FOUND("MEMBER404", "회원 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), INVALID_MEMBER_STATE("MEMBER409", "현재 상태에서는 요청한 상태 변경을 수행할 수 없습니다.", HttpStatus.CONFLICT), + PAGE_MEMBERS_NOT_FOUND("MEMBER404_PAGE", "조회할 수 있는 회원이 없습니다.", HttpStatus.NOT_FOUND), } diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt index d5d166f..181381c 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt @@ -2,11 +2,13 @@ package onku.backend.domain.point.service import onku.backend.domain.attendance.repository.AttendanceRepository 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.* import onku.backend.domain.point.repository.ManualPointRepository import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException import onku.backend.global.page.PageResponse import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service @@ -30,59 +32,59 @@ class AdminPointsService( page: Int, size: Int ): PageResponse { - val safePage = max(0, page) - val pageRequest = PageRequest.of(safePage, size) + 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 { emptyRow(year, it) }) + if (memberIds.isEmpty()) return PageResponse.from(profilePage.map { emptyOverviewRow(it) }) - val startOfYear: LocalDateTime = LocalDateTime.of(year, 1, 1, 0, 0, 0) - val startOfNextYear: LocalDateTime = LocalDateTime.of(year + 1, 1, 1, 0, 0, 0) + // 조회 구간 설정: 8월 ~ 12월 + val startOfAug: LocalDateTime = LocalDateTime.of(year, Month.AUGUST, 1, 0, 0, 0) + val endExclusive: LocalDateTime = LocalDateTime.of(year + 1, 1, 1, 0, 0, 0) - val attendances = attendanceRepository.findByMemberIdInAndAttendanceTimeBetween( - memberIds, startOfYear, startOfNextYear - ) + // 출석 레코드 → 월별 포인트 합산 val monthlyAttendanceTotals: MutableMap> = mutableMapOf() - attendances.forEach { a -> - val m = a.attendanceTime.month.value - if (m in 8..12) { - val memberId = a.memberId - val map = monthlyAttendanceTotals.getOrPut(memberId) { defaultMonthMapInt() } - map[m] = (map[m] ?: 0) + a.status.points + attendanceRepository.findByMemberIdInAndAttendanceTimeBetween(memberIds, startOfAug, endExclusive) + .forEach { attendance -> + val month = attendance.attendanceTime.month.value + val mapForMember = monthlyAttendanceTotals.getOrPut(attendance.memberId) { initMonthScoreMap() } + mapForMember[month] = (mapForMember[month] ?: 0) + attendance.status.points // 동일 월 포인트 합산 } - } - val kupickRows = kupickRepository.findMemberMonthParticipation(memberIds, startOfYear, startOfNextYear) - val kupickMonthMapByMember: MutableMap> = mutableMapOf() - memberIds.forEach { id -> kupickMonthMapByMember[id] = defaultMonthMapBool() } - kupickRows.forEach { row -> - val memberId = (row[0] as Number).toLong() - val m = (row[1] as Number).toInt() - if (m in 8..12) { - kupickMonthMapByMember[memberId]!![m] = true + // 3) 큐픽 참여 여부를 월별로 표시 (기본 false → 참여 시 true) + val kupickParticipationByMember: MutableMap> = mutableMapOf() + memberIds.forEach { id -> kupickParticipationByMember[id] = initMonthParticipationMap() } + 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 manualPoints = manualPointRecordRepository.findByMemberIdIn(memberIds) + // 스터디/큐포터즈/메모 조회 + val manualPointsByMember = manualPointRecordRepository.findByMemberIdIn(memberIds) .associateBy { it.memberId!! } + // 페이지 단위 DTO 변환 val dtoPage = profilePage.map { profile -> - val id = profile.memberId!! - val member = profile.member - val monthTotals = monthlyAttendanceTotals[id] ?: defaultMonthMapInt() - val kupickMap = kupickMonthMapByMember[id] ?: defaultMonthMapBool() - val manual = manualPoints[id] + val memberId = profile.memberId!! + val monthTotals = monthlyAttendanceTotals[memberId] ?: initMonthScoreMap() + val kupickMap = kupickParticipationByMember[memberId] ?: initMonthParticipationMap() + val manual = manualPointsByMember[memberId] AdminPointsRowDto( - memberId = id, + memberId = memberId, name = profile.name, part = profile.part, phoneNumber = profile.phoneNumber, school = profile.school, major = profile.major, - isTf = member.isTf, - isStaff = member.isStaff, + isTf = profile.member.isTf, + isStaff = profile.member.isStaff, attendanceMonthlyTotals = monthTotals.toSortedMap(), kupickParticipation = kupickMap.toSortedMap(), studyPoints = manual?.studyPoints ?: 0, @@ -94,13 +96,13 @@ class AdminPointsService( return PageResponse.from(dtoPage) } - private fun defaultMonthMapInt(): MutableMap = + private fun initMonthScoreMap(): MutableMap = (8..12).associateWith { 0 }.toMutableMap() - private fun defaultMonthMapBool(): MutableMap = + private fun initMonthParticipationMap(): MutableMap = (8..12).associateWith { false }.toMutableMap() - private fun emptyRow(year: Int, profile: onku.backend.domain.member.MemberProfile): AdminPointsRowDto { + private fun emptyOverviewRow(profile: onku.backend.domain.member.MemberProfile): AdminPointsRowDto { return AdminPointsRowDto( memberId = profile.memberId!!, name = profile.name, @@ -127,42 +129,29 @@ class AdminPointsService( ): MonthlyAttendancePageResponse { require(month in 8..12) { "month must be 8..12" } + // 조회 구간 설정 val zone: ZoneId = clock.zone val startZdt = ZonedDateTime.of(LocalDate.of(year, month, 1), LocalTime.MIN, zone) val endZdt = startZdt.plusMonths(1) val start = startZdt.toLocalDateTime() val end = endZdt.toLocalDateTime() + // 세션 시작일 (중복 제거/오름차순) val sessionDates: List = sessionRepository.findStartTimesBetween(start, end) .map { it.toLocalDate() } .distinct() .sorted() val sessionDays: List = sessionDates.map { it.dayOfMonth } + // 멤버 조회 val pageable = PageRequest.of(page, size) val memberPage = memberProfileRepository.findAllByOrderByPartAscNameAsc(pageable) - val pageMemberIds = memberPage.content.mapNotNull { it.memberId } if (pageMemberIds.isEmpty()) { - return MonthlyAttendancePageResponse( - year = year, - month = month, - sessionDates = sessionDays, - members = PageResponse.from( - memberPage.map { p -> - MemberMonthlyAttendanceDto( - memberId = p.memberId!!, - name = p.name ?: "Unknown", - records = emptyList() - ) - } - ) - ) + throw CustomException(MemberErrorCode.PAGE_MEMBERS_NOT_FOUND) } - val attendances = attendanceRepository - .findByMemberIdInAndAttendanceTimeBetween(pageMemberIds, start, end) - + // 해당 페이지 멤버의 월간 출석 레코드 조회 data class Row( val memberId: Long, val date: LocalDate, @@ -171,22 +160,24 @@ class AdminPointsService( val point: Int? ) - val rows: List = attendances.map { a -> - Row( - memberId = a.memberId, - date = a.attendanceTime.toLocalDate(), - attendanceId = a.id, - status = a.status, - point = a.status.points - ) - } + val rows: List = attendanceRepository + .findByMemberIdInAndAttendanceTimeBetween(pageMemberIds, start, end) + .map { a -> + Row( + memberId = a.memberId, + date = a.attendanceTime.toLocalDate(), + attendanceId = a.id, + status = a.status, + point = a.status.points + ) + } - val nameById = memberPage.content.associate { it.memberId!! to (it.name ?: "Unknown") } - val byMember = rows.groupBy { it.memberId } + val rowsByMember = rows.groupBy { it.memberId } // 멤버별로 레코드 그룹핑 + // 멤버별 일자 정렬 + 세션일 기준 결측 레코드 채움 val memberDtos = memberPage.content.map { profile -> - val mid = profile.memberId!! - val base = byMember[mid] + val memberId = profile.memberId!! + val baseRecords = rowsByMember[memberId] ?.sortedBy { it.date } ?.map { AttendanceRecordDto( @@ -199,28 +190,30 @@ class AdminPointsService( ?.toMutableList() ?: mutableListOf() + // 세션일이 존재하지만 기록이 없는 날짜는 null 처리 if (sessionDates.isNotEmpty()) { - val recorded = base.map { it.date }.toSet() - sessionDates.filter { it !in recorded }.forEach { d -> - base.add( + val recordedDates = baseRecords.map { it.date }.toSet() + sessionDates.filter { it !in recordedDates }.forEach { date -> + baseRecords.add( AttendanceRecordDto( - date = d, + date = date, attendanceId = null, status = null, point = null ) ) } - base.sortBy { it.date } + baseRecords.sortBy { it.date } // 날짜 오름차순 정렬 } MemberMonthlyAttendanceDto( - memberId = mid, - name = nameById[mid] ?: "Unknown", - records = base + memberId = memberId, + name = profile.name ?: "Unknown", + records = baseRecords ) } + // 멤버 페이지 순서를 유지하며 DTO 페이지 구성 val dtoPage = memberPage.map { p -> memberDtos.first { it.memberId == p.memberId } } From a656abe15ba866dc604cd90e28068422f6a578ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 20 Oct 2025 23:40:01 +0900 Subject: [PATCH 165/470] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/point/service/AdminPointsService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt index 181381c..493ee23 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt @@ -53,7 +53,7 @@ class AdminPointsService( mapForMember[month] = (mapForMember[month] ?: 0) + attendance.status.points // 동일 월 포인트 합산 } - // 3) 큐픽 참여 여부를 월별로 표시 (기본 false → 참여 시 true) + // 큐픽 참여 여부를 월별로 표시 (기본 false → 참여 시 true) val kupickParticipationByMember: MutableMap> = mutableMapOf() memberIds.forEach { id -> kupickParticipationByMember[id] = initMonthParticipationMap() } kupickRepository.findMemberMonthParticipation(memberIds, startOfAug, endExclusive) From fc77648f32cdb57bd22320a3a763177f7c9e4d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 00:14:38 +0900 Subject: [PATCH 166/470] =?UTF-8?q?feat:=20=ED=81=90=ED=94=BD=20=EB=A0=88?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=83=9D=EC=84=B1=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/kupick/Kupick.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt index 4cce914..d5d48d7 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt @@ -49,6 +49,18 @@ class Kupick( submitDate = applicationDate ) } + + fun createKupick(member: Member, nowDate: LocalDateTime): Kupick { + return Kupick( + member = member, + submitDate = nowDate, + applicationImageUrl = "null", + applicationDate = null, + viewImageUrl = null, + viewDate = null, + approval = false + ) + } } fun submitView(viewImageUrl: String, nowDate: LocalDateTime) { From 81f475fbe6aa2a5ba73f4c5f919969148b55d236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 00:15:46 +0900 Subject: [PATCH 167/470] =?UTF-8?q?feat:=20=EC=9B=94=EB=B3=84=20=ED=81=90?= =?UTF-8?q?=ED=94=BD=20=EC=88=98=EA=B0=95=20=EC=97=AC=EB=B6=80=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=EC=A7=84=20=EC=88=98=EC=A0=95=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20dto=20=EC=B6=94=EA=B0=80=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/point/dto/UpdateKupickRequest.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateKupickRequest.kt diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupickRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupickRequest.kt new file mode 100644 index 0000000..56a90f7 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupickRequest.kt @@ -0,0 +1,10 @@ +package onku.backend.domain.point.dto + +import jakarta.validation.constraints.NotNull + +data class UpdateKupickRequest( + @field:NotNull + val memberId: Long?, + @field:NotNull + val isKupick: Boolean? +) \ No newline at end of file From 71dd1d7714d43d7ddbb81fd5b8cf492083aa25fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 00:16:16 +0900 Subject: [PATCH 168/470] =?UTF-8?q?feat:=20=ED=81=90=ED=94=BD=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EC=98=88=EC=99=B8=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/kupick/KupickErrorCode.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt 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..0fdd0d8 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt @@ -0,0 +1,12 @@ +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), +} \ No newline at end of file From 20cd322add0bb5456442be791bffc7af86ca1353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 00:16:50 +0900 Subject: [PATCH 169/470] =?UTF-8?q?feat:=20=EC=9B=94=EB=B3=84=20=ED=81=90?= =?UTF-8?q?=ED=94=BD=20=EC=88=98=EA=B0=95=20=EC=97=AC=EB=B6=80=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=EC=A7=84=20=EC=88=98=EC=A0=95=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminPointsCommandService.kt | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt index 4825ddb..fcedf67 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt @@ -1,5 +1,8 @@ package onku.backend.domain.point.service +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 @@ -7,11 +10,16 @@ import onku.backend.domain.point.repository.ManualPointRepository 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 @Service class AdminPointsCommandService( private val manualPointRecordRepository: ManualPointRepository, - private val memberRepository: MemberRepository + private val memberRepository: MemberRepository, + private val kupickRepository: KupickRepository, + private val clock: Clock ) { @Transactional @@ -54,4 +62,30 @@ class AdminPointsCommandService( memo = null ) } + + @Transactional + fun updateKupickApproval(memberId: Long, isKupick: Boolean): Long { + val member = memberRepository.findById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + val now = LocalDateTime.now(clock) + val startOfMonth = LocalDate.now(clock).withDayOfMonth(1).atStartOfDay() + val startOfNextMonth = startOfMonth.toLocalDate().plusMonths(1).atStartOfDay() + + val existing = kupickRepository.findThisMonthByMember( + member = member, + start = startOfMonth, + end = startOfNextMonth + ) + + val target = existing ?: run { + val created = Kupick.createKupick(member, now) + kupickRepository.save(created) + } + + target.updateApproval(isKupick) + + return kupickRepository.save(target).id + ?: throw CustomException(KupickErrorCode.KUPICK_SAVE_FAILED) + } } From 7f0e4471e58e939b309ca04a2425854123e678a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 00:17:09 +0900 Subject: [PATCH 170/470] =?UTF-8?q?feat:=20=EC=9B=94=EB=B3=84=20=ED=81=90?= =?UTF-8?q?=ED=94=BD=20=EC=88=98=EA=B0=95=20=EC=97=AC=EB=B6=80=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=EC=A7=84=20=EC=88=98=EC=A0=95=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20endpoint=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/controller/AdminPointsController.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt index 3b3dc7f..39dabb0 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt @@ -65,6 +65,19 @@ class AdminPointsController( return ResponseEntity.ok(SuccessResponse.ok(Unit)) } + @PatchMapping("/kupick") + @Operation( + summary = "이번 달 큐픽 존재 여부 수정", + description = "memberId, isKupick(boolean)을 받아 이번 달 큐픽 레코드를 승인/미승인 처리합니다." + + "이번 달 제출 레코드가 없으면 먼저 생성한 뒤 동일하게 approval을 갱신합니다." + ) + fun updateKupick( + @RequestBody @Valid req: UpdateKupickRequest + ): ResponseEntity> { + commandService.updateKupickApproval(req.memberId!!, req.isKupick!!) + return ResponseEntity.ok(SuccessResponse.ok(Unit)) + } + @GetMapping("/monthly") @Operation( summary = "월간 출석 현황 [운영진]", From 9e588f7899eaf480a4dd98a17d2b39169f8b6b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 16:43:30 +0900 Subject: [PATCH 171/470] =?UTF-8?q?chore:=20=EA=B3=B5=ED=86=B5=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=EC=97=90=20=EC=9E=88=EB=8D=98=20?= =?UTF-8?q?=ED=81=90=ED=94=BD=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/kupick/KupickErrorCode.kt | 2 ++ .../onku/backend/domain/kupick/service/KupickService.kt | 5 +++-- src/main/kotlin/onku/backend/global/exception/ErrorCode.kt | 2 -- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt b/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt index 0fdd0d8..e938ac6 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt @@ -9,4 +9,6 @@ enum class KupickErrorCode( 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), } \ 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 index feccd08..6ff67b6 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -1,6 +1,7 @@ package onku.backend.domain.kupick.service import onku.backend.domain.kupick.Kupick +import onku.backend.domain.kupick.KupickErrorCode import onku.backend.domain.kupick.repository.KupickRepository import onku.backend.domain.kupick.repository.projection.KupickUrls import onku.backend.domain.kupick.repository.projection.KupickWithProfile @@ -40,7 +41,7 @@ class KupickService( val kupick = kupickRepository.findThisMonthByMember( member, monthObject.startOfMonth, - monthObject.startOfNextMonth) ?: throw CustomException(ErrorCode.KUPICK_APPLICATION_FIRST) + monthObject.startOfNextMonth) ?: throw CustomException(KupickErrorCode.KUPICK_APPLICATION_FIRST) kupick.submitView(viewUrl, now) } @@ -67,7 +68,7 @@ class KupickService( @Transactional fun decideApproval(kupickId: Long, approval: Boolean) { val kupick = kupickRepository.findByIdOrNull(kupickId) - ?: throw CustomException(ErrorCode.KUPICK_NOT_FOUND) + ?: throw CustomException(KupickErrorCode.KUPICK_NOT_FOUND) kupick.updateApproval(approval) } } \ 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 index 1f7459d..7089415 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -15,8 +15,6 @@ enum class ErrorCode( PARAMETER_VALIDATION_ERROR("COMMON422", "파라미터 검증 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), PARAMETER_GRAMMAR_ERROR("COMMON422", "파라미터 문법 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), INVALID_FILE_EXTENSION("S3001", "올바르지 않은 파일 확장자 입니다.", HttpStatus.BAD_REQUEST), - KUPICK_APPLICATION_FIRST("kupick001", "큐픽 신청부터 진행해주세요", HttpStatus.BAD_REQUEST), - KUPICK_NOT_FOUND("kupick002", "해당 큐픽 객체를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST), SESSION_NOT_FOUND("session001", "해당 세션이 존재하지 않습니다.", HttpStatus.NOT_FOUND), SESSION_PAST("session002", "이미 지난 세션입니다.", HttpStatus.BAD_REQUEST), From 2f3b66c26eb78a863185ee643ce5c4d27aa159a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 21 Oct 2025 16:46:31 +0900 Subject: [PATCH 172/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A0=95=EA=B7=9C=ED=99=94=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/session/Session.kt | 27 +++++-------------- .../backend/domain/session/SessionDetail.kt | 26 ++++++++++++++++++ .../backend/domain/session/SessionImage.kt | 21 +++++++++++++++ .../repository/SessionDetailRepository.kt | 8 ++++++ .../repository/SessionImageRepository.kt | 8 ++++++ .../session/repository/SessionRepository.kt | 11 +++++--- .../domain/session/service/SessionService.kt | 2 +- 7 files changed, 78 insertions(+), 25 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/session/SessionDetail.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/SessionImage.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/repository/SessionDetailRepository.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt diff --git a/src/main/kotlin/onku/backend/domain/session/Session.kt b/src/main/kotlin/onku/backend/domain/session/Session.kt index 7bb4e09..878094a 100644 --- a/src/main/kotlin/onku/backend/domain/session/Session.kt +++ b/src/main/kotlin/onku/backend/domain/session/Session.kt @@ -3,7 +3,7 @@ package onku.backend.domain.session import jakarta.persistence.* import onku.backend.domain.session.enums.SessionCategory import onku.backend.global.entity.BaseEntity -import java.time.LocalDateTime +import java.time.LocalDate @Entity @Table(name = "session") @@ -12,17 +12,15 @@ class Session( @Column(name = "session_id") val id: Long? = null, + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_detail_id") + val sessionDetail: SessionDetail, + @Column(name = "title", nullable = false, length = 255) val title: String, - @Column(name = "start_time", nullable = false) - val startTime: LocalDateTime, - - @Column(name = "end_time", nullable = false) - val endTime: LocalDateTime, - - @Column(name = "place", nullable = false, length = 255) - val place: String, + @Column(name = "start_date", nullable = false) + val startDate: LocalDate, @Enumerated(EnumType.STRING) @Column(name = "category", nullable = false, length = 32) @@ -31,15 +29,4 @@ class Session( @Column(name = "week", nullable = false) val week: Long, - @Column(name = "is_reward", nullable = false) - val isReward: Boolean, - - @Column(name = "open_grace_seconds", nullable = false) // 세션 시작 이전 N초부터 출석 허용 - val openGraceSeconds: Long = 0, - - @Column(name = "close_grace_seconds", nullable = false) // 세션 종료 이후 N초까지 출석 허용 - val closeGraceSeconds: Long = 0, - - @Column(name = "late_threshold_time", nullable = false) // 지각 기준 시각 - val lateThresholdTime: LocalDateTime ) : BaseEntity() 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..c821a7a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt @@ -0,0 +1,26 @@ +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") + val place : String, + + @Column(name = "start_time") + val startTime : LocalTime, + + @Column(name = "end_time") + val endTime : LocalTime, + + @Column(name = "content") + val content : String +) : BaseEntity() { +} \ 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/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..9263717 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.session.repository + +import onku.backend.domain.session.SessionImage +import org.springframework.data.repository.CrudRepository + +interface SessionImageRepository : CrudRepository { + +} \ 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 index 1ccadaa..59d279c 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable 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 SessionRepository : CrudRepository { @@ -24,13 +25,15 @@ interface SessionRepository : CrudRepository { ) fun findOpenSession(@Param("now") now: LocalDateTime): Session? - @Query(""" + @Query( + """ SELECT s FROM Session s - WHERE s.startTime >= :now AND s.category <> :restCategory - """) + WHERE s.startDate >= :now AND s.category <> :restCategory + """ + ) fun findUpcomingSessions( - @Param("now") now: LocalDateTime, + @Param("now") now: LocalDate, pageable: Pageable, @Param("restCategory") restCategory: SessionCategory = SessionCategory.REST ): Page diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index 539246a..9a63eb7 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -25,7 +25,7 @@ class SessionService( fun getUpcomingSessionsForAbsence(pageable: Pageable): Page { val now = LocalDateTime.now(clock) - val sessions = sessionRepository.findUpcomingSessions(now, pageable) + val sessions = sessionRepository.findUpcomingSessions(now.toLocalDate(), pageable) return sessions.map { s -> val active = sessionValidator.isImminentSession(s, now) From 6fffe56314ae292acfaaf6dcc50d9296a73c9fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 21 Oct 2025 16:49:21 +0900 Subject: [PATCH 173/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A0=95=EA=B7=9C=ED=99=94?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=88=98=EC=A0=95=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B0=98=EC=98=81=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../absence/dto/response/GetMyAbsenceReportResponse.kt | 3 ++- .../onku/backend/domain/absence/facade/AbsenceFacade.kt | 2 +- .../domain/absence/repository/AbsenceReportRepository.kt | 8 +++++--- .../repository/projection/GetMyAbsenceReportView.kt | 3 ++- .../onku/backend/domain/absence/service/AbsenceService.kt | 2 +- .../domain/attendance/service/AttendanceService.kt | 3 +-- .../backend/domain/session/validator/SessionValidator.kt | 4 ++-- 7 files changed, 14 insertions(+), 11 deletions(-) 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 index d78e155..b12e09d 100644 --- a/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMyAbsenceReportResponse.kt +++ b/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMyAbsenceReportResponse.kt @@ -2,6 +2,7 @@ package onku.backend.domain.absence.dto.response import onku.backend.domain.absence.enums.AbsenceReportApproval import onku.backend.domain.absence.enums.AbsenceType +import java.time.LocalDate import java.time.LocalDateTime data class GetMyAbsenceReportResponse( @@ -10,5 +11,5 @@ data class GetMyAbsenceReportResponse( val absenceReportApproval : AbsenceReportApproval, val submitDateTime : LocalDateTime, val sessionTitle : String, - val sessionStartDateTime: LocalDateTime + val sessionStartDate: LocalDate ) diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index bb35979..4d96d63 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -51,7 +51,7 @@ class AbsenceFacade( absenceReportApproval = v.absenceReportApproval, submitDateTime = v.submitDateTime, sessionTitle = v.sessionTitle, - sessionStartDateTime = v.sessionStartDateTime + sessionStartDate = v.sessionStartDate ) } return PageResponse.from(responses) diff --git a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt index 954201c..81d758f 100644 --- a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -10,18 +10,20 @@ import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param interface AbsenceReportRepository : JpaRepository { - @Query(""" + @Query( + """ select ar.id as absenceReportId, ar.status as absenceType, ar.approval as absenceReportApproval, ar.createdAt as submitDateTime, s.title as sessionTitle, - s.startTime as sessionStartDateTime + s.startDate as sessionStartDateTime from AbsenceReport ar join ar.session s where ar.member = :member - """) + """ + ) fun findMyAbsenceReports( @Param("member") member: Member, pageable: Pageable 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 index 2c5e8d2..a16aa9b 100644 --- a/src/main/kotlin/onku/backend/domain/absence/repository/projection/GetMyAbsenceReportView.kt +++ b/src/main/kotlin/onku/backend/domain/absence/repository/projection/GetMyAbsenceReportView.kt @@ -2,6 +2,7 @@ package onku.backend.domain.absence.repository.projection import onku.backend.domain.absence.enums.AbsenceReportApproval import onku.backend.domain.absence.enums.AbsenceType +import java.time.LocalDate import java.time.LocalDateTime interface GetMyAbsenceReportView { @@ -10,5 +11,5 @@ interface GetMyAbsenceReportView { fun getAbsenceReportApproval(): AbsenceReportApproval fun getSubmitDateTime(): LocalDateTime fun getSessionTitle(): String - fun getSessionStartDateTime(): LocalDateTime + 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 index 26ba326..a4e94e4 100644 --- a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -46,7 +46,7 @@ class AbsenceService( absenceReportApproval = v.getAbsenceReportApproval(), submitDateTime = v.getSubmitDateTime(), sessionTitle = v.getSessionTitle(), - sessionStartDateTime = v.getSessionStartDateTime() + sessionStartDate = v.getSessionStartDateTime() ) } } diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 2b60a2d..3937304 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -58,9 +58,8 @@ class AttendanceService( val consumed = tokenCache.consumeToken(token) ?: throw CustomException(ErrorCode.UNAUTHORIZED) - val state = - if (now.isAfter(session.lateThresholdTime)) AttendanceStatus.LATE + if (now.toLocalTime().isAfter(session.sessionDetail.startTime)) AttendanceStatus.LATE else AttendanceStatus.PRESENT try { diff --git a/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt b/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt index 447d65f..15d13fc 100644 --- a/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt +++ b/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt @@ -15,7 +15,7 @@ class SessionValidator { /** 지난 세션 여부 */ fun isPastSession(session: Session, now: LocalDateTime = LocalDateTime.now(zone)): Boolean { - return session.endTime.isBefore(now) + return session.startDate.isBefore(now.toLocalDate()) } /** 금/토에 바로 앞 토요일 세션인지 여부 */ @@ -25,7 +25,7 @@ class SessionValidator { if (!isFriOrSat) return false val upcomingSaturday = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) - val sessionDate = session.startTime.atZone(zone).toLocalDate() + val sessionDate = session.startDate return sessionDate == upcomingSaturday } From 5aa14226ba23d62c218dbe5deb5cade1cb0785c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 16:54:50 +0900 Subject: [PATCH 174/470] =?UTF-8?q?feat:=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=20=EC=84=A4=EC=A0=95=20=EC=8B=9C=20TimeRangeUtil=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=B4=EC=84=9C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/point/service/AdminPointsService.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt index 493ee23..2d7390b 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt @@ -15,6 +15,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.* import kotlin.math.max +import onku.backend.global.time.TimeRangeUtil @Service class AdminPointsService( @@ -41,8 +42,10 @@ class AdminPointsService( if (memberIds.isEmpty()) return PageResponse.from(profilePage.map { emptyOverviewRow(it) }) // 조회 구간 설정: 8월 ~ 12월 - val startOfAug: LocalDateTime = LocalDateTime.of(year, Month.AUGUST, 1, 0, 0, 0) - val endExclusive: LocalDateTime = LocalDateTime.of(year + 1, 1, 1, 0, 0, 0) + 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 // 출석 레코드 → 월별 포인트 합산 val monthlyAttendanceTotals: MutableMap> = mutableMapOf() @@ -130,11 +133,9 @@ class AdminPointsService( require(month in 8..12) { "month must be 8..12" } // 조회 구간 설정 - val zone: ZoneId = clock.zone - val startZdt = ZonedDateTime.of(LocalDate.of(year, month, 1), LocalTime.MIN, zone) - val endZdt = startZdt.plusMonths(1) - val start = startZdt.toLocalDateTime() - val end = endZdt.toLocalDateTime() + val monthRange = TimeRangeUtil.monthRange(year, month, clock.zone) + val start: LocalDateTime = monthRange.startOfMonth + val end: LocalDateTime = monthRange.startOfNextMonth // 세션 시작일 (중복 제거/오름차순) val sessionDates: List = sessionRepository.findStartTimesBetween(start, end) From 4d7ada96c2169368416e249d9c9a476614201b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 21 Oct 2025 18:08:38 +0900 Subject: [PATCH 175/470] =?UTF-8?q?feat=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=20=EC=A0=80=EC=9E=A5=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/service/AttendanceService.kt | 2 +- .../onku/backend/domain/session/Session.kt | 2 +- .../backend/domain/session/SessionDetail.kt | 8 ++--- .../manager/SessionManagerController.kt | 31 +++++++++++++++++++ .../{ => user}/SessionController.kt | 2 +- .../domain/session/dto/SessionSaveRequest.kt | 13 ++++++++ .../domain/session/enums/SessionCategory.kt | 6 ++-- .../domain/session/facade/SessionFacade.kt | 5 +++ .../domain/session/service/SessionService.kt | 16 ++++++++++ 9 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt rename src/main/kotlin/onku/backend/domain/session/controller/{ => user}/SessionController.kt (96%) create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/SessionSaveRequest.kt diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 3937304..4a164aa 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -59,7 +59,7 @@ class AttendanceService( val consumed = tokenCache.consumeToken(token) ?: throw CustomException(ErrorCode.UNAUTHORIZED) val state = - if (now.toLocalTime().isAfter(session.sessionDetail.startTime)) AttendanceStatus.LATE + if (now.toLocalTime().isAfter(session.sessionDetail!!.startTime)) AttendanceStatus.LATE else AttendanceStatus.PRESENT try { diff --git a/src/main/kotlin/onku/backend/domain/session/Session.kt b/src/main/kotlin/onku/backend/domain/session/Session.kt index 878094a..506e693 100644 --- a/src/main/kotlin/onku/backend/domain/session/Session.kt +++ b/src/main/kotlin/onku/backend/domain/session/Session.kt @@ -14,7 +14,7 @@ class Session( @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "session_detail_id") - val sessionDetail: SessionDetail, + val sessionDetail: SessionDetail? = null, @Column(name = "title", nullable = false, length = 255) val title: String, diff --git a/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt b/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt index c821a7a..c0a2827 100644 --- a/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt +++ b/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt @@ -12,15 +12,15 @@ class SessionDetail( val id: Long? = null, @Column(name = "place") - val place : String, + val place : String?, @Column(name = "start_time") - val startTime : LocalTime, + val startTime : LocalTime?, @Column(name = "end_time") - val endTime : LocalTime, + val endTime : LocalTime?, @Column(name = "content") - val content : String + val content : String? ) : BaseEntity() { } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt new file mode 100644 index 0000000..b889219 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt @@ -0,0 +1,31 @@ +package onku.backend.domain.session.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.session.dto.SessionSaveRequest +import onku.backend.domain.session.facade.SessionFacade +import onku.backend.global.response.SuccessResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/session/manager") +@Tag(name = "[관리자용] 세션 API", description = "세션 관련 API") +class SessionManagerController( + private val sessionFacade: SessionFacade +) { + @PostMapping("") + @Operation( + summary = "세션 일정 저장", + description = "세션 일정들을 저장합니다." + ) + fun sessionSave( + @RequestBody @Valid sessionSaveRequestList : List + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.sessionSave(sessionSaveRequestList))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/controller/SessionController.kt b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt similarity index 96% rename from src/main/kotlin/onku/backend/domain/session/controller/SessionController.kt rename to src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt index 8d8bdf6..95dcc67 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/SessionController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.session.controller +package onku.backend.domain.session.controller.user import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag diff --git a/src/main/kotlin/onku/backend/domain/session/dto/SessionSaveRequest.kt b/src/main/kotlin/onku/backend/domain/session/dto/SessionSaveRequest.kt new file mode 100644 index 0000000..787c5f7 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/SessionSaveRequest.kt @@ -0,0 +1,13 @@ +package onku.backend.domain.session.dto + +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 + ) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt index 2a4ffbe..7e7af03 100644 --- a/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt +++ b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt @@ -4,10 +4,12 @@ import io.swagger.v3.oas.annotations.media.Schema @Schema( description = "세션 종류", - example = "GENERAL" + example = "CORPORATE_PROJECT" ) enum class SessionCategory { - @Schema(description = "일반 세션") GENERAL, + @Schema(description = "기업 프로젝트") CORPORATE_PROJECT, + @Schema(description = "밋업 프로젝트") MEETUP_PROJECT, + @Schema(description = "네트워킹") NETWORKING, @Schema(description = "공휴일 특별 세션 (가산점 1점)") HOLIDAY, @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 index 192629d..92991c0 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -1,6 +1,7 @@ package onku.backend.domain.session.facade import onku.backend.domain.session.dto.SessionAboutAbsenceResponse +import onku.backend.domain.session.dto.SessionSaveRequest import onku.backend.domain.session.service.SessionService import onku.backend.global.page.PageResponse import org.springframework.data.domain.PageRequest @@ -15,4 +16,8 @@ class SessionFacade( val sessionPage = sessionService.getUpcomingSessionsForAbsence(pageRequest) return PageResponse.from(sessionPage) } + + fun sessionSave(sessionSaveRequestList: List): Boolean { + return sessionService.saveAll(sessionSaveRequestList) + } } \ 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 index 9a63eb7..0ea87d5 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -3,6 +3,7 @@ package onku.backend.domain.session.service import onku.backend.domain.session.validator.SessionValidator import onku.backend.domain.session.Session import onku.backend.domain.session.dto.SessionAboutAbsenceResponse +import onku.backend.domain.session.dto.SessionSaveRequest import onku.backend.domain.session.repository.SessionRepository import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode @@ -42,4 +43,19 @@ class SessionService( fun getById(id : Long) : Session { return sessionRepository.findByIdOrNull(id) ?: throw CustomException(ErrorCode.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 + ) + } + sessionRepository.saveAll(sessions) + return true + } } \ No newline at end of file From 792ae10aa32967d1c2977dd51ac303b916c94045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 20:10:54 +0900 Subject: [PATCH 176/470] =?UTF-8?q?refactor:=20point=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=9D=98=20dto=20=EC=A0=95=EB=A6=AC=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/dto/AttendanceResponse.kt | 4 +-- ...PointsDtos.kt => AdminPointOverviewDto.kt} | 2 +- .../domain/point/dto/AttendanceRecordDto.kt | 11 ------- .../point/dto/MemberMonthlyAttendanceDto.kt | 7 ----- .../dto/MonthlyAttendancePageResponse.kt | 17 +++++++++- .../domain/point/dto/UpdateIsTfRequest.kt | 8 ----- .../domain/point/dto/UpdateKupickRequest.kt | 10 ------ .../dto/UpdateKupportersPointsRequest.kt | 8 ----- .../point/dto/UpdateManualPointRequest.kt | 31 +++++++++++++++++++ .../domain/point/dto/UpdateMemoRequest.kt | 9 ------ .../point/dto/UpdateStudyPointsRequest.kt | 8 ----- .../domain/point/dto/UserPointResponse.kt | 24 ++++++++++++++ 12 files changed, 74 insertions(+), 65 deletions(-) rename src/main/kotlin/onku/backend/domain/point/dto/{AdminPointsDtos.kt => AdminPointOverviewDto.kt} (93%) delete mode 100644 src/main/kotlin/onku/backend/domain/point/dto/AttendanceRecordDto.kt delete mode 100644 src/main/kotlin/onku/backend/domain/point/dto/MemberMonthlyAttendanceDto.kt delete mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateIsTfRequest.kt delete mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateKupickRequest.kt delete mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateKupportersPointsRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt delete mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateMemoRequest.kt delete mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateStudyPointsRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UserPointResponse.kt diff --git a/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt index 984a179..d182c04 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt @@ -1,12 +1,12 @@ package onku.backend.domain.attendance.dto -import onku.backend.domain.attendance.enums.AttendanceStatus +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: AttendanceStatus, + val state: AttendancePointType, val scannedAt: LocalDateTime ) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/AdminPointsDtos.kt b/src/main/kotlin/onku/backend/domain/point/dto/AdminPointOverviewDto.kt similarity index 93% rename from src/main/kotlin/onku/backend/domain/point/dto/AdminPointsDtos.kt rename to src/main/kotlin/onku/backend/domain/point/dto/AdminPointOverviewDto.kt index 4335655..a6e1d9a 100644 --- a/src/main/kotlin/onku/backend/domain/point/dto/AdminPointsDtos.kt +++ b/src/main/kotlin/onku/backend/domain/point/dto/AdminPointOverviewDto.kt @@ -2,7 +2,7 @@ package onku.backend.domain.point.dto import onku.backend.domain.member.enums.Part -data class AdminPointsRowDto( +data class AdminPointOverviewDto( val memberId: Long, val name: String?, val part: Part, diff --git a/src/main/kotlin/onku/backend/domain/point/dto/AttendanceRecordDto.kt b/src/main/kotlin/onku/backend/domain/point/dto/AttendanceRecordDto.kt deleted file mode 100644 index ef2e22e..0000000 --- a/src/main/kotlin/onku/backend/domain/point/dto/AttendanceRecordDto.kt +++ /dev/null @@ -1,11 +0,0 @@ -package onku.backend.domain.point.dto - -import onku.backend.domain.attendance.enums.AttendanceStatus -import java.time.LocalDate - -data class AttendanceRecordDto( - val date: LocalDate, - val attendanceId: Long?, - val status: AttendanceStatus?, - val point: Int? -) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/MemberMonthlyAttendanceDto.kt b/src/main/kotlin/onku/backend/domain/point/dto/MemberMonthlyAttendanceDto.kt deleted file mode 100644 index c7d086d..0000000 --- a/src/main/kotlin/onku/backend/domain/point/dto/MemberMonthlyAttendanceDto.kt +++ /dev/null @@ -1,7 +0,0 @@ -package onku.backend.domain.point.dto - -data class MemberMonthlyAttendanceDto( - val memberId: Long, - val name: String, - val records: List -) \ 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 index 2e39a62..07b74b8 100644 --- a/src/main/kotlin/onku/backend/domain/point/dto/MonthlyAttendancePageResponse.kt +++ b/src/main/kotlin/onku/backend/domain/point/dto/MonthlyAttendancePageResponse.kt @@ -1,10 +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/UpdateIsTfRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateIsTfRequest.kt deleted file mode 100644 index 32d810c..0000000 --- a/src/main/kotlin/onku/backend/domain/point/dto/UpdateIsTfRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package onku.backend.domain.point.dto - -import jakarta.validation.constraints.NotNull - -data class UpdateIsTfRequest( - @field:NotNull val memberId: Long?, - @field:NotNull val isTf: Boolean? -) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupickRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupickRequest.kt deleted file mode 100644 index 56a90f7..0000000 --- a/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupickRequest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package onku.backend.domain.point.dto - -import jakarta.validation.constraints.NotNull - -data class UpdateKupickRequest( - @field:NotNull - val memberId: Long?, - @field:NotNull - val isKupick: Boolean? -) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupportersPointsRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupportersPointsRequest.kt deleted file mode 100644 index 1ac3326..0000000 --- a/src/main/kotlin/onku/backend/domain/point/dto/UpdateKupportersPointsRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package onku.backend.domain.point.dto - -import jakarta.validation.constraints.NotNull - -data class UpdateKupportersPointsRequest( - @field:NotNull val memberId: Long?, - @field:NotNull val kuportersPoints: Int? -) \ 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..bb64988 --- /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 + +sealed interface UpdateManualPointRequest + +data class UpdateIsTfRequest( + @field:NotNull val memberId: Long?, + @field:NotNull val isTf: Boolean? +) : UpdateManualPointRequest + +data class UpdateKupickRequest( + @field:NotNull val memberId: Long?, + @field:NotNull val isKupick: Boolean? +) : UpdateManualPointRequest + +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/UpdateMemoRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateMemoRequest.kt deleted file mode 100644 index b31036d..0000000 --- a/src/main/kotlin/onku/backend/domain/point/dto/UpdateMemoRequest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package onku.backend.domain.point.dto - -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.NotNull - -data class UpdateMemoRequest( - @field:NotNull val memberId: Long?, - @field:NotBlank val memo: String? -) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateStudyPointsRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateStudyPointsRequest.kt deleted file mode 100644 index fe1dae2..0000000 --- a/src/main/kotlin/onku/backend/domain/point/dto/UpdateStudyPointsRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package onku.backend.domain.point.dto - -import jakarta.validation.constraints.NotNull - -data class UpdateStudyPointsRequest( - @field:NotNull val memberId: Long?, - @field:NotNull val studyPoints: Int? -) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UserPointResponse.kt b/src/main/kotlin/onku/backend/domain/point/dto/UserPointResponse.kt new file mode 100644 index 0000000..1ed314a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/dto/UserPointResponse.kt @@ -0,0 +1,24 @@ +package onku.backend.domain.point.dto + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "단일 상/벌점 레코드 응답") +data class UserPointRecordResponse( + val date: String, + val type: String, + val points: Int, + val week: Int? = null, + val attendanceTime: String? = null, + val earlyLeaveTime: String? = null +) + +@Schema(description = "사용자 상/벌점 이력 응답") +data class UserPointHistoryResponse( + val memberId: Long, + val plusPoints: Int, + val minusPoints: Int, + val totalPoints: Int, + val records: List, + val totalPages: Int, + val isLastPage: Boolean +) From e546da965b6912f1fd3186b1a0bf5f15248cfef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 20:12:38 +0900 Subject: [PATCH 177/470] =?UTF-8?q?refactor:=20=EC=A0=90=EC=88=98=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20enum=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ndanceStatus.kt => AttendancePointType.kt} | 7 ++++-- .../domain/point/enums/ManualPointType.kt | 24 +++++++++++++++++++ .../domain/point/enums/PointCategory.kt | 6 +++++ 3 files changed, 35 insertions(+), 2 deletions(-) rename src/main/kotlin/onku/backend/domain/attendance/enums/{AttendanceStatus.kt => AttendancePointType.kt} (85%) create mode 100644 src/main/kotlin/onku/backend/domain/point/enums/ManualPointType.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/enums/PointCategory.kt diff --git a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendancePointType.kt similarity index 85% rename from src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt rename to src/main/kotlin/onku/backend/domain/attendance/enums/AttendancePointType.kt index 5bc1bc6..62cc661 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceStatus.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendancePointType.kt @@ -3,7 +3,7 @@ package onku.backend.domain.attendance.enums import io.swagger.v3.oas.annotations.media.Schema @Schema(description = "출석 상태") -enum class AttendanceStatus( +enum class AttendancePointType( @Schema(description = "해당 출석 상태의 점수") val points: Int ) { @@ -23,5 +23,8 @@ enum class AttendanceStatus( ABSENT_WITH_DOC(-2), @Schema(description = "지각: -1점") - LATE(-1); + LATE(-1), + + @Schema(description = "조퇴: -1점") + EARLY_LEAVE(-1) } \ No newline at end of file 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 From c79e4cffa27aa0ffdfff60b5766fa03932ec0240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 20:26:12 +0900 Subject: [PATCH 178/470] =?UTF-8?q?refactor:=20points=20=E2=86=92=20point?= =?UTF-8?q?=20=EB=A1=9C=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ntsController.kt => AdminPointController.kt} | 12 ++++++------ ...ndService.kt => AdminPointCommandService.kt} | 2 +- ...minPointsService.kt => AdminPointService.kt} | 17 ++++++++++------- 3 files changed, 17 insertions(+), 14 deletions(-) rename src/main/kotlin/onku/backend/domain/point/controller/{AdminPointsController.kt => AdminPointController.kt} (92%) rename src/main/kotlin/onku/backend/domain/point/service/{AdminPointsCommandService.kt => AdminPointCommandService.kt} (99%) rename src/main/kotlin/onku/backend/domain/point/service/{AdminPointsService.kt => AdminPointService.kt} (95%) diff --git a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt similarity index 92% rename from src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt rename to src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt index 39dabb0..28d262a 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointsController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt @@ -4,8 +4,8 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import onku.backend.domain.point.dto.* -import onku.backend.domain.point.service.AdminPointsCommandService -import onku.backend.domain.point.service.AdminPointsService +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 @@ -17,9 +17,9 @@ import org.springframework.web.bind.annotation.* name = "운영진 상벌점", description = "운영진 상벌점 대시보드 조회 API" ) -class AdminPointsController( - private val adminPointsService: AdminPointsService, - private val commandService: AdminPointsCommandService +class AdminPointController( + private val adminPointsService: AdminPointService, + private val commandService: AdminPointCommandService ) { @GetMapping("/overview") @@ -30,7 +30,7 @@ class AdminPointsController( fun overview( @RequestParam(defaultValue = "1") page: Int, @RequestParam(defaultValue = "10") size: Int - ): ResponseEntity>> { + ): ResponseEntity>> { val safePage = if (page < 1) 0 else page - 1 val year = 2025 val body = adminPointsService.getAdminOverview(year, safePage, size) diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt similarity index 99% rename from src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt rename to src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt index fcedf67..e7d8a06 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsCommandService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt @@ -15,7 +15,7 @@ import java.time.LocalDate import java.time.LocalDateTime @Service -class AdminPointsCommandService( +class AdminPointCommandService( private val manualPointRecordRepository: ManualPointRepository, private val memberRepository: MemberRepository, private val kupickRepository: KupickRepository, diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt similarity index 95% rename from src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt rename to src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt index 2d7390b..903aacc 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointsService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt @@ -5,7 +5,10 @@ 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.* +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.repository.ManualPointRepository import onku.backend.domain.session.repository.SessionRepository import onku.backend.global.exception.CustomException @@ -18,7 +21,7 @@ import kotlin.math.max import onku.backend.global.time.TimeRangeUtil @Service -class AdminPointsService( +class AdminPointService( private val memberProfileRepository: MemberProfileRepository, private val attendanceRepository: AttendanceRepository, private val kupickRepository: KupickRepository, @@ -32,7 +35,7 @@ class AdminPointsService( year: Int, page: Int, size: Int - ): PageResponse { + ): PageResponse { val safePageIndex = max(0, page) val pageRequest = PageRequest.of(safePageIndex, size) @@ -79,7 +82,7 @@ class AdminPointsService( val kupickMap = kupickParticipationByMember[memberId] ?: initMonthParticipationMap() val manual = manualPointsByMember[memberId] - AdminPointsRowDto( + AdminPointOverviewDto( memberId = memberId, name = profile.name, part = profile.part, @@ -105,8 +108,8 @@ class AdminPointsService( private fun initMonthParticipationMap(): MutableMap = (8..12).associateWith { false }.toMutableMap() - private fun emptyOverviewRow(profile: onku.backend.domain.member.MemberProfile): AdminPointsRowDto { - return AdminPointsRowDto( + private fun emptyOverviewRow(profile: onku.backend.domain.member.MemberProfile): AdminPointOverviewDto { + return AdminPointOverviewDto( memberId = profile.memberId!!, name = profile.name, part = profile.part, @@ -157,7 +160,7 @@ class AdminPointsService( val memberId: Long, val date: LocalDate, val attendanceId: Long?, - val status: onku.backend.domain.attendance.enums.AttendanceStatus?, + val status: onku.backend.domain.attendance.enums.AttendancePointType?, val point: Int? ) From aedaa2ec3f88e942d3f86a9e50d02ec3a099c9c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 20:33:24 +0900 Subject: [PATCH 179/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=EC=A0=90=EC=88=98=20=EA=B8=B0=EB=A1=9D=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=9A=A9=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/point/MemberPoint.kt | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/MemberPoint.kt diff --git a/src/main/kotlin/onku/backend/domain/point/MemberPoint.kt b/src/main/kotlin/onku/backend/domain/point/MemberPoint.kt new file mode 100644 index 0000000..e4bffd2 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/MemberPoint.kt @@ -0,0 +1,109 @@ +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 MemberPoint( + @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) + val type: String, + + @Column(name = "points", nullable = false) + val points: Int, + + @Column(name = "occurred_at", nullable = false) + val occurredAt: LocalDateTime, + + @Column(name = "week") + val week: Int? = 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: Int, + time: LocalTime? = null + ): MemberPoint { + return when (status) { + AttendancePointType.EARLY_LEAVE -> MemberPoint( + member = member, + category = PointCategory.ATTENDANCE, + type = status.name, + points = status.points, // -1 + occurredAt = occurredAt, + week = week, + earlyLeaveTime = time + ) + AttendancePointType.PRESENT, + AttendancePointType.PRESENT_HOLIDAY, + AttendancePointType.LATE -> MemberPoint( + 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 -> MemberPoint( + member = member, + category = PointCategory.ATTENDANCE, + type = status.name, + points = status.points, + occurredAt = occurredAt, + week = week + ) + } + } + + fun ofManual( + member: Member, + manualType: ManualPointType, + occurredAt: LocalDateTime + ): MemberPoint { + return MemberPoint( + member = member, + category = PointCategory.MANUAL, + type = manualType.name, + points = manualType.points, + occurredAt = occurredAt + ) + } + } +} From 16b0a5234b79411d8821bb10cc7ef23b37504386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 20:41:32 +0900 Subject: [PATCH 180/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=B3=84=20=EC=A0=90=EC=88=98=20=ED=95=A9=EC=82=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A0=90=EC=88=98=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/converter/MemberPointConverter.kt | 20 +++++++++ .../point/repository/MemberPointRepository.kt | 30 +++++++++++++ .../point/service/MemberPointService.kt | 42 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/converter/MemberPointConverter.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/repository/MemberPointRepository.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/service/MemberPointService.kt 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..0ebd414 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/converter/MemberPointConverter.kt @@ -0,0 +1,20 @@ +package onku.backend.domain.point.converter + +import onku.backend.domain.point.MemberPoint +import onku.backend.domain.point.dto.UserPointRecordResponse +import java.time.format.DateTimeFormatter + +object MemberPointConverter { + private val dateFmt = DateTimeFormatter.ofPattern("MM/dd") + private val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + + fun toResponse(r: MemberPoint): UserPointRecordResponse = + UserPointRecordResponse( + 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/repository/MemberPointRepository.kt b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointRepository.kt new file mode 100644 index 0000000..d6e0dd9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointRepository.kt @@ -0,0 +1,30 @@ +package onku.backend.domain.point.repository + +import onku.backend.domain.member.Member +import onku.backend.domain.point.MemberPoint +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 + +interface MemberPointRepository : 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 MemberPoint r + WHERE r.member = :member + """ + ) + fun sumPointsForMember(member: Member): MemberPointSums +} diff --git a/src/main/kotlin/onku/backend/domain/point/service/MemberPointService.kt b/src/main/kotlin/onku/backend/domain/point/service/MemberPointService.kt new file mode 100644 index 0000000..f9c9980 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/service/MemberPointService.kt @@ -0,0 +1,42 @@ +package onku.backend.domain.point.service + +import onku.backend.domain.member.Member +import onku.backend.domain.point.converter.MemberPointConverter +import onku.backend.domain.point.dto.UserPointHistoryResponse +import onku.backend.domain.point.repository.MemberPointRepository +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import kotlin.math.max + +@Service +class MemberPointService( + private val recordRepository: MemberPointRepository +) { + + @Transactional(readOnly = true) + fun getHistory(member: Member, page1Based: Int, size: Int): UserPointHistoryResponse { + val safePage = max(0, page1Based - 1) + val pageable = PageRequest.of(safePage, size) + + // 누적 합계 + 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 UserPointHistoryResponse( + memberId = member.id!!, + plusPoints = plusPoints, + minusPoints = minusPoints, + totalPoints = totalPoints, + records = records, + totalPages = page.totalPages, + isLastPage = page.isLast + ) + } +} From 30d9f29695d583c7e7daad8892e59aa7c54df7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 20:42:06 +0900 Subject: [PATCH 181/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=B3=84=20=EC=83=81=EB=B2=8C=EC=A0=90=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?endpoint=20=EC=B6=94=EA=B0=80=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/controller/MemberPointController.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt diff --git a/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt b/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt new file mode 100644 index 0000000..0ae23b9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt @@ -0,0 +1,38 @@ +package onku.backend.domain.point.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.domain.point.dto.UserPointHistoryResponse +import onku.backend.domain.point.service.MemberPointService +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: MemberPointService +) { + @GetMapping("/history") + @Operation( + summary = "사용자 상/벌점 이력 조회", + description = "회원의 상/벌점 누적 합 및 날짜순(최신순) 이력을 페이징하여 반환" + ) + fun history( + @CurrentMember member: Member, + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int + ): ResponseEntity> { + val body = memberPointService.getHistory(member, page, size) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } +} From 97ef11dc99e91286a557ac9943420840bfb585f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 21 Oct 2025 20:42:25 +0900 Subject: [PATCH 182/470] =?UTF-8?q?chore:=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EC=A0=81=EC=9A=A9=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/attendance/Attendance.kt | 4 ++-- .../backend/domain/attendance/service/AttendanceService.kt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt b/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt index 2eac757..edfb786 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/Attendance.kt @@ -1,7 +1,7 @@ package onku.backend.domain.attendance import jakarta.persistence.* -import onku.backend.domain.attendance.enums.AttendanceStatus +import onku.backend.domain.attendance.enums.AttendancePointType import onku.backend.global.entity.BaseEntity import java.time.LocalDateTime @@ -29,5 +29,5 @@ class Attendance( @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 32) - var status: AttendanceStatus + var status: AttendancePointType ) : BaseEntity() diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 2b60a2d..e393807 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -2,7 +2,7 @@ package onku.backend.domain.attendance.service import onku.backend.domain.attendance.AttendanceErrorCode import onku.backend.domain.attendance.dto.* -import onku.backend.domain.attendance.enums.AttendanceStatus +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 @@ -60,8 +60,8 @@ class AttendanceService( ?: throw CustomException(ErrorCode.UNAUTHORIZED) val state = - if (now.isAfter(session.lateThresholdTime)) AttendanceStatus.LATE - else AttendanceStatus.PRESENT + if (now.isAfter(session.lateThresholdTime)) AttendancePointType.LATE + else AttendancePointType.PRESENT try { attendanceRepository.insertOnly( From 36be0806f9ad695dc1db5b37ad020da7db5f40ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 22 Oct 2025 13:00:40 +0900 Subject: [PATCH 183/470] =?UTF-8?q?chore=20:=20=EC=A3=BC=EC=B0=A8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=97=90=20=EC=9C=A0=EB=8B=88?= =?UTF-8?q?=ED=81=AC=EC=A0=9C=EC=95=BD=20=EB=84=A3=EA=B8=B0=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/session/Session.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/session/Session.kt b/src/main/kotlin/onku/backend/domain/session/Session.kt index 506e693..316855f 100644 --- a/src/main/kotlin/onku/backend/domain/session/Session.kt +++ b/src/main/kotlin/onku/backend/domain/session/Session.kt @@ -26,7 +26,7 @@ class Session( @Column(name = "category", nullable = false, length = 32) val category: SessionCategory, - @Column(name = "week", nullable = false) + @Column(name = "week", nullable = false, unique = true) val week: Long, ) : BaseEntity() From 4c186e0d898db4b2a53d0bb34c2bdbe9cbac56fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 22 Oct 2025 13:11:17 +0900 Subject: [PATCH 184/470] =?UTF-8?q?feat=20:=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/SessionManagerController.kt | 22 ++++++++++++++----- .../controller/user/SessionController.kt | 2 +- .../dto/SessionAboutAbsenceResponse.kt | 8 ------- .../dto/{ => request}/SessionSaveRequest.kt | 2 +- .../dto/response/GetInitialSessionResponse.kt | 18 +++++++++++++++ .../response/SessionAboutAbsenceResponse.kt | 14 ++++++++++++ .../domain/session/facade/SessionFacade.kt | 11 ++++++++-- .../session/repository/SessionRepository.kt | 7 ++++++ .../domain/session/service/SessionService.kt | 19 ++++++++++++++-- 9 files changed, 84 insertions(+), 19 deletions(-) delete mode 100644 src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt rename src/main/kotlin/onku/backend/domain/session/dto/{ => request}/SessionSaveRequest.kt (89%) create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/response/GetInitialSessionResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/response/SessionAboutAbsenceResponse.kt diff --git a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt index b889219..a54b0bc 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt @@ -3,14 +3,13 @@ package onku.backend.domain.session.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.session.dto.SessionSaveRequest +import onku.backend.domain.session.dto.request.SessionSaveRequest +import onku.backend.domain.session.dto.response.GetInitialSessionResponse import onku.backend.domain.session.facade.SessionFacade +import onku.backend.global.page.PageResponse import onku.backend.global.response.SuccessResponse import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/session/manager") @@ -28,4 +27,17 @@ class SessionManagerController( ) : 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))) + } } \ No newline at end of file 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 index 95dcc67..a4a5b31 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt @@ -2,7 +2,7 @@ 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.SessionAboutAbsenceResponse +import onku.backend.domain.session.dto.response.SessionAboutAbsenceResponse import onku.backend.domain.session.facade.SessionFacade import onku.backend.global.page.PageResponse import onku.backend.global.response.SuccessResponse diff --git a/src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt b/src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt deleted file mode 100644 index 52bea66..0000000 --- a/src/main/kotlin/onku/backend/domain/session/dto/SessionAboutAbsenceResponse.kt +++ /dev/null @@ -1,8 +0,0 @@ -package onku.backend.domain.session.dto - -data class SessionAboutAbsenceResponse( - val sessionId : Long?, - val title : String, - val week : Long, - val active : Boolean -) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/SessionSaveRequest.kt b/src/main/kotlin/onku/backend/domain/session/dto/request/SessionSaveRequest.kt similarity index 89% rename from src/main/kotlin/onku/backend/domain/session/dto/SessionSaveRequest.kt rename to src/main/kotlin/onku/backend/domain/session/dto/request/SessionSaveRequest.kt index 787c5f7..ab34daa 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/SessionSaveRequest.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/request/SessionSaveRequest.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.session.dto +package onku.backend.domain.session.dto.request import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull 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..979b45b --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/GetInitialSessionResponse.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 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? +) 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..228dae2 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/SessionAboutAbsenceResponse.kt @@ -0,0 +1,14 @@ +package onku.backend.domain.session.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +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 = "true") + val active : Boolean +) diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index 92991c0..b132f16 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -1,7 +1,8 @@ package onku.backend.domain.session.facade -import onku.backend.domain.session.dto.SessionAboutAbsenceResponse -import onku.backend.domain.session.dto.SessionSaveRequest +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.service.SessionService import onku.backend.global.page.PageResponse import org.springframework.data.domain.PageRequest @@ -20,4 +21,10 @@ class SessionFacade( 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) + } } \ 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 index 59d279c..06cf12d 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -37,4 +37,11 @@ interface SessionRepository : CrudRepository { pageable: Pageable, @Param("restCategory") restCategory: SessionCategory = SessionCategory.REST ): Page + + + @Query(""" + SELECT s + FROM Session s + """) + fun findAll(pageable: Pageable): Page } diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index 0ea87d5..4c44f8a 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -2,8 +2,9 @@ package onku.backend.domain.session.service import onku.backend.domain.session.validator.SessionValidator import onku.backend.domain.session.Session -import onku.backend.domain.session.dto.SessionAboutAbsenceResponse -import onku.backend.domain.session.dto.SessionSaveRequest +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.repository.SessionRepository import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode @@ -58,4 +59,18 @@ class SessionService( 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 + ) + } + } } \ No newline at end of file From 09812fadba87c039c479971dc01c4ad2976ef0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 22 Oct 2025 14:23:25 +0900 Subject: [PATCH 185/470] =?UTF-8?q?feat=20:=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20upsert=20=EA=B5=AC=ED=98=84=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/session/Session.kt | 9 +++- .../backend/domain/session/SessionDetail.kt | 17 ++++---- .../domain/session/SessionErrorCode.kt | 12 ++++++ .../annotation/SessionValidTimeRange.kt | 15 +++++++ .../manager/SessionManagerController.kt | 14 +++++- .../dto/request/UpsertSessionDetailRequest.kt | 20 +++++++++ .../domain/session/facade/SessionFacade.kt | 10 ++++- .../session/service/SessionDetailService.kt | 43 +++++++++++++++++++ .../session/validator/SessionTimeValidator.kt | 15 +++++++ 9 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/annotation/SessionValidTimeRange.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/request/UpsertSessionDetailRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/validator/SessionTimeValidator.kt diff --git a/src/main/kotlin/onku/backend/domain/session/Session.kt b/src/main/kotlin/onku/backend/domain/session/Session.kt index 316855f..dfec56e 100644 --- a/src/main/kotlin/onku/backend/domain/session/Session.kt +++ b/src/main/kotlin/onku/backend/domain/session/Session.kt @@ -12,9 +12,14 @@ class Session( @Column(name = "session_id") val id: Long? = null, - @OneToOne(fetch = FetchType.LAZY) + @OneToOne( + fetch = FetchType.LAZY, + cascade = [CascadeType.PERSIST, CascadeType.MERGE], + optional = true, + orphanRemoval = true + ) @JoinColumn(name = "session_detail_id") - val sessionDetail: SessionDetail? = null, + var sessionDetail: SessionDetail? = null, @Column(name = "title", nullable = false, length = 255) val title: String, diff --git a/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt b/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt index c0a2827..b6a3311 100644 --- a/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt +++ b/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt @@ -11,16 +11,17 @@ class SessionDetail( @Column(name = "session_detail_id") val id: Long? = null, - @Column(name = "place") - val place : String?, + @Column(name = "place", nullable = false) + var place: String, - @Column(name = "start_time") - val startTime : LocalTime?, + @Column(name = "start_time", nullable = false) + var startTime: LocalTime, - @Column(name = "end_time") - val endTime : LocalTime?, + @Column(name = "end_time", nullable = false) + var endTime: LocalTime, - @Column(name = "content") - val content : String? + @Column(name = "content", nullable = false) + 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..f708ce9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt @@ -0,0 +1,12 @@ +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) +} \ 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/manager/SessionManagerController.kt b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt index a54b0bc..d30da15 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import onku.backend.domain.session.dto.request.SessionSaveRequest +import onku.backend.domain.session.dto.request.UpsertSessionDetailRequest import onku.backend.domain.session.dto.response.GetInitialSessionResponse import onku.backend.domain.session.facade.SessionFacade import onku.backend.global.page.PageResponse @@ -40,4 +41,15 @@ class SessionManagerController( val safePage = if (page < 1) 0 else page - 1 return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.getInitialSession(safePage, size))) } -} \ No newline at end of file + + @PostMapping("/detail") + @Operation( + summary = "세션 상세정보 upsert", + description = "세션 상세정보를 새로 입력하거나 수정합니다." + ) + fun upsertSessionDetail( + @RequestBody @Valid upsertSessionDetailRequest : UpsertSessionDetailRequest + ) : ResponseEntity> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.upsertSessionDetail(upsertSessionDetailRequest))) + } +} 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..79fd14d --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/request/UpsertSessionDetailRequest.kt @@ -0,0 +1,20 @@ +package onku.backend.domain.session.dto.request + +import com.fasterxml.jackson.annotation.JsonFormat +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") + val startTime : LocalTime, + @field:NotNull + @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm:ss") + val endTime : LocalTime, + @field:NotBlank val content: String, +) diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index b132f16..d759770 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -2,7 +2,9 @@ package onku.backend.domain.session.facade import onku.backend.domain.session.dto.response.SessionAboutAbsenceResponse import onku.backend.domain.session.dto.request.SessionSaveRequest +import onku.backend.domain.session.dto.request.UpsertSessionDetailRequest import onku.backend.domain.session.dto.response.GetInitialSessionResponse +import onku.backend.domain.session.service.SessionDetailService import onku.backend.domain.session.service.SessionService import onku.backend.global.page.PageResponse import org.springframework.data.domain.PageRequest @@ -10,7 +12,8 @@ import org.springframework.stereotype.Component @Component class SessionFacade( - private val sessionService: SessionService + private val sessionService: SessionService, + private val sessionDetailService: SessionDetailService ) { fun showSessionAboutAbsence(page: Int, size: Int): PageResponse { val pageRequest = PageRequest.of(page, size) @@ -27,4 +30,9 @@ class SessionFacade( val initialSessionPage = sessionService.getInitialSession(pageRequest) return PageResponse.from(initialSessionPage) } + + fun upsertSessionDetail(upsertSessionDetailRequest: UpsertSessionDetailRequest): Long { + val session = sessionService.getById(upsertSessionDetailRequest.sessionId) + return sessionDetailService.upsertSessionDetail(session, upsertSessionDetailRequest) + } } \ 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..a10fa72 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt @@ -0,0 +1,43 @@ +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.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.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SessionDetailService( + private val sessionDetailRepository: SessionDetailRepository, + private val sessionRepository: SessionRepository +) { + @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) + return (detail.id ?: 0L) + } +} \ No newline at end of file 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 From 1baccee66760bc60219bc4dde01ff339df620ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 22 Oct 2025 14:25:43 +0900 Subject: [PATCH 186/470] =?UTF-8?q?refactor=20:=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20upsert=20response=20dto=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/controller/manager/SessionManagerController.kt | 5 ++++- .../session/dto/response/UpsertSessionDetailResponse.kt | 5 +++++ .../onku/backend/domain/session/facade/SessionFacade.kt | 5 +++-- 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/response/UpsertSessionDetailResponse.kt diff --git a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt index d30da15..5cf6a26 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt @@ -6,6 +6,7 @@ import jakarta.validation.Valid import onku.backend.domain.session.dto.request.SessionSaveRequest import onku.backend.domain.session.dto.request.UpsertSessionDetailRequest import onku.backend.domain.session.dto.response.GetInitialSessionResponse +import onku.backend.domain.session.dto.response.UpsertSessionDetailResponse import onku.backend.domain.session.facade.SessionFacade import onku.backend.global.page.PageResponse import onku.backend.global.response.SuccessResponse @@ -49,7 +50,9 @@ class SessionManagerController( ) fun upsertSessionDetail( @RequestBody @Valid upsertSessionDetailRequest : UpsertSessionDetailRequest - ) : ResponseEntity> { + ) : ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.upsertSessionDetail(upsertSessionDetailRequest))) } + + } 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/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index d759770..9562f26 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -4,6 +4,7 @@ import onku.backend.domain.session.dto.response.SessionAboutAbsenceResponse import onku.backend.domain.session.dto.request.SessionSaveRequest import onku.backend.domain.session.dto.request.UpsertSessionDetailRequest import onku.backend.domain.session.dto.response.GetInitialSessionResponse +import onku.backend.domain.session.dto.response.UpsertSessionDetailResponse import onku.backend.domain.session.service.SessionDetailService import onku.backend.domain.session.service.SessionService import onku.backend.global.page.PageResponse @@ -31,8 +32,8 @@ class SessionFacade( return PageResponse.from(initialSessionPage) } - fun upsertSessionDetail(upsertSessionDetailRequest: UpsertSessionDetailRequest): Long { + fun upsertSessionDetail(upsertSessionDetailRequest: UpsertSessionDetailRequest): UpsertSessionDetailResponse { val session = sessionService.getById(upsertSessionDetailRequest.sessionId) - return sessionDetailService.upsertSessionDetail(session, upsertSessionDetailRequest) + return UpsertSessionDetailResponse(sessionDetailService.upsertSessionDetail(session, upsertSessionDetailRequest)) } } \ No newline at end of file From fddab9390a5799516e782bef6498d5898cbb132c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 22 Oct 2025 16:26:49 +0900 Subject: [PATCH 187/470] =?UTF-8?q?feat=20:=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/SessionManagerController.kt | 16 +++++++++ .../dto/request/UploadSessionImageRequest.kt | 8 +++++ .../domain/session/facade/SessionFacade.kt | 33 +++++++++++++++++-- .../session/service/SessionDetailService.kt | 7 ++++ .../session/service/SessionImageService.kt | 20 +++++++++++ .../backend/global/s3/enums/FolderName.kt | 3 +- 6 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/request/UploadSessionImageRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt diff --git a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt index 5cf6a26..b000433 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt @@ -3,13 +3,17 @@ package onku.backend.domain.session.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.Member 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.GetInitialSessionResponse 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.* @@ -54,5 +58,17 @@ class SessionManagerController( 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))) + } + } 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..7aa3d86 --- /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 : String +) \ 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 index 9562f26..05a915e 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -1,20 +1,28 @@ package onku.backend.domain.session.facade +import onku.backend.domain.member.Member import onku.backend.domain.session.dto.response.SessionAboutAbsenceResponse 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.GetInitialSessionResponse import onku.backend.domain.session.dto.response.UpsertSessionDetailResponse import onku.backend.domain.session.service.SessionDetailService +import onku.backend.domain.session.service.SessionImageService import onku.backend.domain.session.service.SessionService 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.service.S3Service import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Component @Component class SessionFacade( private val sessionService: SessionService, - private val sessionDetailService: SessionDetailService + private val sessionDetailService: SessionDetailService, + private val sessionImageService : SessionImageService, + private val s3Service: S3Service ) { fun showSessionAboutAbsence(page: Int, size: Int): PageResponse { val pageRequest = PageRequest.of(page, size) @@ -34,6 +42,27 @@ class SessionFacade( fun upsertSessionDetail(upsertSessionDetailRequest: UpsertSessionDetailRequest): UpsertSessionDetailResponse { val session = sessionService.getById(upsertSessionDetailRequest.sessionId) - return UpsertSessionDetailResponse(sessionDetailService.upsertSessionDetail(session, upsertSessionDetailRequest)) + return UpsertSessionDetailResponse( + sessionDetailService.upsertSessionDetail( + session, + upsertSessionDetailRequest + ) + ) + } + + fun uploadSessionImage( + member: Member, + uploadSessionImageRequest: UploadSessionImageRequest + ): GetPreSignedUrlDto { + val sessionDetail = sessionDetailService.getById(uploadSessionImageRequest.sessionDetailId) + val getS3UrlDto = s3Service.getPostS3Url( + member.id!!, + uploadSessionImageRequest.imageFileName, + FolderName.SESSION.name + ) + sessionImageService.uploadImage(getS3UrlDto.key, sessionDetail) + return GetPreSignedUrlDto( + getS3UrlDto.preSignedUrl + ) } } \ 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 index a10fa72..245a65f 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt @@ -40,4 +40,11 @@ class SessionDetailService( sessionRepository.save(session) 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..c4d40bd --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt @@ -0,0 +1,20 @@ +package onku.backend.domain.session.service + +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionImage +import onku.backend.domain.session.repository.SessionImageRepository +import org.springframework.stereotype.Service + +@Service +class SessionImageService( + private val sessionImageRepository: SessionImageRepository, +) { + fun uploadImage(key: String, sessionDetail: SessionDetail) { + sessionImageRepository.save( + SessionImage( + sessionDetail = sessionDetail, + url = key + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt index f919f4f..214e9a1 100644 --- a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt +++ b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt @@ -3,5 +3,6 @@ package onku.backend.global.s3.enums enum class FolderName{ KUPICK_APPLICATION, KUPICK_VIEW, - ABSENCE + ABSENCE, + SESSION } \ No newline at end of file From a5e3448dee624a5a65edb6e048797c9c0e1704b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 22 Oct 2025 17:38:24 +0900 Subject: [PATCH 188/470] =?UTF-8?q?feat=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=82=AD=EC=A0=9C=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/SessionErrorCode.kt | 3 ++- .../manager/SessionManagerController.kt | 14 ++++++++++++- .../dto/request/DeleteSessionImageRequest.kt | 5 +++++ .../response/UploadSessionImageResponse.kt | 6 ++++++ .../domain/session/facade/SessionFacade.kt | 16 +++++++++++++-- .../session/service/SessionImageService.kt | 17 ++++++++++++++-- .../backend/global/s3/service/S3Service.kt | 20 +++++++++++++++++++ 7 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/request/DeleteSessionImageRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/response/UploadSessionImageResponse.kt diff --git a/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt b/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt index f708ce9..d03db31 100644 --- a/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt +++ b/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt @@ -8,5 +8,6 @@ enum class SessionErrorCode( override val message: String, override val status: HttpStatus ) : ApiErrorCode { - SESSION_DETAIL_NOT_FOUND("SESSION_DETAIL404", "세션 상세정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND) + SESSION_DETAIL_NOT_FOUND("SESSION_DETAIL404", "세션 상세정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + SESSION_IMAGE_NOT_FOUND("SESSION_IMAGE404", "세션 이미지를 찾을 수 없습니다.", HttpStatus.NOT_FOUND) } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt index b000433..8a01ea0 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt @@ -4,10 +4,12 @@ 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.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 @@ -66,9 +68,19 @@ class SessionManagerController( fun uploadSessionImage( @CurrentMember member: Member, @RequestBody uploadSessionImageRequest : UploadSessionImageRequest - ) : ResponseEntity> { + ) : 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))) + } } 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/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/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index 05a915e..79f39a7 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -1,11 +1,13 @@ package onku.backend.domain.session.facade import onku.backend.domain.member.Member +import onku.backend.domain.session.dto.request.DeleteSessionImageRequest import onku.backend.domain.session.dto.response.SessionAboutAbsenceResponse 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.GetInitialSessionResponse +import onku.backend.domain.session.dto.response.UploadSessionImageResponse import onku.backend.domain.session.dto.response.UpsertSessionDetailResponse import onku.backend.domain.session.service.SessionDetailService import onku.backend.domain.session.service.SessionImageService @@ -53,14 +55,24 @@ class SessionFacade( fun uploadSessionImage( member: Member, uploadSessionImageRequest: UploadSessionImageRequest - ): GetPreSignedUrlDto { + ): UploadSessionImageResponse { val sessionDetail = sessionDetailService.getById(uploadSessionImageRequest.sessionDetailId) val getS3UrlDto = s3Service.getPostS3Url( member.id!!, uploadSessionImageRequest.imageFileName, FolderName.SESSION.name ) - sessionImageService.uploadImage(getS3UrlDto.key, sessionDetail) + val sessionImage = sessionImageService.uploadImage(getS3UrlDto.key, sessionDetail) + return UploadSessionImageResponse( + sessionImage.id!!, + getS3UrlDto.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 ) diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt index c4d40bd..4581910 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt @@ -1,20 +1,33 @@ 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 org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional @Service class SessionImageService( private val sessionImageRepository: SessionImageRepository, ) { - fun uploadImage(key: String, sessionDetail: SessionDetail) { - sessionImageRepository.save( + fun uploadImage(key: String, sessionDetail: SessionDetail) : SessionImage { + return sessionImageRepository.save( SessionImage( sessionDetail = sessionDetail, url = key ) ) } + + @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)} + } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt index 84edb5b..ac7ff80 100644 --- a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -6,9 +6,11 @@ import onku.backend.global.s3.dto.GetS3UrlDto import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest import software.amazon.awssdk.services.s3.model.GetObjectRequest import software.amazon.awssdk.services.s3.model.PutObjectRequest import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.DeleteObjectPresignRequest import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest import java.net.URL @@ -65,6 +67,24 @@ class S3Service( return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key) } + @Transactional(readOnly = true) + fun getDeleteS3Url(key: String): GetS3UrlDto { + val deleteReq = DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build() + + val presignReq = DeleteObjectPresignRequest.builder() + .signatureDuration(DEFAULT_EXPIRE) + .deleteObjectRequest(deleteReq) + .build() + + val presigned = s3Presigner.presignDeleteObject(presignReq) + val url: URL = presigned.url() + + return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key) + } + private fun guessContentType(filename: String): String { val lower = filename.lowercase() return when { From 5d17aba23b795e7c7a0427aefd3a0eff8186d6ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 22 Oct 2025 18:48:09 +0900 Subject: [PATCH 189/470] =?UTF-8?q?chore:=20MemberPoint=20=E2=86=92=20Memb?= =?UTF-8?q?erPointHistory=EB=A1=9C=20=ED=85=8C=EC=9D=B4=EB=B8=94=EB=AA=85?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=ED=95=B4=EC=84=9C=20=EC=9D=98=EB=8F=84=20?= =?UTF-8?q?=EB=93=9C=EB=9F=AC=EB=82=B4=EA=B8=B0=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{MemberPoint.kt => MemberPointHistory.kt} | 14 +++++++------- .../domain/point/converter/MemberPointConverter.kt | 4 ++-- .../point/repository/MemberPointRepository.kt | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) rename src/main/kotlin/onku/backend/domain/point/{MemberPoint.kt => MemberPointHistory.kt} (92%) diff --git a/src/main/kotlin/onku/backend/domain/point/MemberPoint.kt b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt similarity index 92% rename from src/main/kotlin/onku/backend/domain/point/MemberPoint.kt rename to src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt index e4bffd2..b93c47b 100644 --- a/src/main/kotlin/onku/backend/domain/point/MemberPoint.kt +++ b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt @@ -17,7 +17,7 @@ import java.time.LocalTime Index(name = "idx_member_point_type", columnList = "type") ] ) -class MemberPoint( +class MemberPointHistory( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "record_id") @@ -57,9 +57,9 @@ class MemberPoint( occurredAt: LocalDateTime, week: Int, time: LocalTime? = null - ): MemberPoint { + ): MemberPointHistory { return when (status) { - AttendancePointType.EARLY_LEAVE -> MemberPoint( + AttendancePointType.EARLY_LEAVE -> MemberPointHistory( member = member, category = PointCategory.ATTENDANCE, type = status.name, @@ -70,7 +70,7 @@ class MemberPoint( ) AttendancePointType.PRESENT, AttendancePointType.PRESENT_HOLIDAY, - AttendancePointType.LATE -> MemberPoint( + AttendancePointType.LATE -> MemberPointHistory( member = member, category = PointCategory.ATTENDANCE, type = status.name, @@ -81,7 +81,7 @@ class MemberPoint( ) AttendancePointType.EXCUSED, AttendancePointType.ABSENT, - AttendancePointType.ABSENT_WITH_DOC -> MemberPoint( + AttendancePointType.ABSENT_WITH_DOC -> MemberPointHistory( member = member, category = PointCategory.ATTENDANCE, type = status.name, @@ -96,8 +96,8 @@ class MemberPoint( member: Member, manualType: ManualPointType, occurredAt: LocalDateTime - ): MemberPoint { - return MemberPoint( + ): MemberPointHistory { + return MemberPointHistory( member = member, category = PointCategory.MANUAL, type = manualType.name, diff --git a/src/main/kotlin/onku/backend/domain/point/converter/MemberPointConverter.kt b/src/main/kotlin/onku/backend/domain/point/converter/MemberPointConverter.kt index 0ebd414..721b657 100644 --- a/src/main/kotlin/onku/backend/domain/point/converter/MemberPointConverter.kt +++ b/src/main/kotlin/onku/backend/domain/point/converter/MemberPointConverter.kt @@ -1,6 +1,6 @@ package onku.backend.domain.point.converter -import onku.backend.domain.point.MemberPoint +import onku.backend.domain.point.MemberPointHistory import onku.backend.domain.point.dto.UserPointRecordResponse import java.time.format.DateTimeFormatter @@ -8,7 +8,7 @@ object MemberPointConverter { private val dateFmt = DateTimeFormatter.ofPattern("MM/dd") private val timeFmt = DateTimeFormatter.ofPattern("HH:mm") - fun toResponse(r: MemberPoint): UserPointRecordResponse = + fun toResponse(r: MemberPointHistory): UserPointRecordResponse = UserPointRecordResponse( date = r.occurredAt.toLocalDate().format(dateFmt), type = r.type, diff --git a/src/main/kotlin/onku/backend/domain/point/repository/MemberPointRepository.kt b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointRepository.kt index d6e0dd9..53fd7a0 100644 --- a/src/main/kotlin/onku/backend/domain/point/repository/MemberPointRepository.kt +++ b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointRepository.kt @@ -1,15 +1,15 @@ package onku.backend.domain.point.repository import onku.backend.domain.member.Member -import onku.backend.domain.point.MemberPoint +import onku.backend.domain.point.MemberPointHistory 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 -interface MemberPointRepository : JpaRepository { +interface MemberPointRepository : JpaRepository { - fun findByMemberOrderByOccurredAtDesc(member: Member, pageable: Pageable): Page + fun findByMemberOrderByOccurredAtDesc(member: Member, pageable: Pageable): Page interface MemberPointSums { fun getPlusPoints(): Long @@ -22,7 +22,7 @@ interface MemberPointRepository : JpaRepository { 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 MemberPoint r + FROM MemberPointHistory r WHERE r.member = :member """ ) From 8ca7dabb8af707f52a91e074c1442a4aca5a985b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 22 Oct 2025 19:23:30 +0900 Subject: [PATCH 190/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=8B=9C?= =?UTF-8?q?=20Attendance=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=95=98=EB=8D=98=20=EC=A0=90=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20MemberPointHistory=EC=97=90=EB=8F=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/service/AttendanceService.kt | 18 ++++++++++++++++++ .../backend/domain/point/MemberPointHistory.kt | 6 +++--- .../point/controller/MemberPointController.kt | 8 ++++---- .../point/converter/MemberPointConverter.kt | 5 ++--- .../domain/point/dto/UserPointResponse.kt | 8 ++++---- ...tory.kt => MemberPointHistoryRepository.kt} | 2 +- ...Service.kt => MemberPointHistoryService.kt} | 12 ++++++------ 7 files changed, 38 insertions(+), 21 deletions(-) rename src/main/kotlin/onku/backend/domain/point/repository/{MemberPointRepository.kt => MemberPointHistoryRepository.kt} (92%) rename src/main/kotlin/onku/backend/domain/point/service/{MemberPointService.kt => MemberPointHistoryService.kt} (80%) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index e393807..3b2d1a0 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -1,11 +1,15 @@ 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.dto.* 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.repository.SessionRepository import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode @@ -23,7 +27,9 @@ class AttendanceService( 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 ) { private val ttlSeconds: Long = 15L @@ -72,6 +78,18 @@ class AttendanceService( 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) } diff --git a/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt index b93c47b..978d019 100644 --- a/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt +++ b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt @@ -41,7 +41,7 @@ class MemberPointHistory( val occurredAt: LocalDateTime, @Column(name = "week") - val week: Int? = null, + val week: Long? = null, @Column(name = "attendance_time") val attendanceTime: LocalTime? = null, @@ -55,7 +55,7 @@ class MemberPointHistory( member: Member, status: AttendancePointType, occurredAt: LocalDateTime, - week: Int, + week: Long, time: LocalTime? = null ): MemberPointHistory { return when (status) { @@ -63,7 +63,7 @@ class MemberPointHistory( member = member, category = PointCategory.ATTENDANCE, type = status.name, - points = status.points, // -1 + points = status.points, occurredAt = occurredAt, week = week, earlyLeaveTime = time diff --git a/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt b/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt index 0ae23b9..3ad21f3 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt @@ -3,8 +3,8 @@ package onku.backend.domain.point.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.domain.point.dto.UserPointHistoryResponse -import onku.backend.domain.point.service.MemberPointService +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 @@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController description = "사용자 상벌점 조회 API" ) class MemberPointController( - private val memberPointService: MemberPointService + private val memberPointService: MemberPointHistoryService ) { @GetMapping("/history") @Operation( @@ -31,7 +31,7 @@ class MemberPointController( @CurrentMember member: Member, @RequestParam(defaultValue = "1") page: Int, @RequestParam(defaultValue = "10") size: Int - ): ResponseEntity> { + ): ResponseEntity> { val body = memberPointService.getHistory(member, page, 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 index 721b657..d8bc4cc 100644 --- a/src/main/kotlin/onku/backend/domain/point/converter/MemberPointConverter.kt +++ b/src/main/kotlin/onku/backend/domain/point/converter/MemberPointConverter.kt @@ -1,15 +1,14 @@ package onku.backend.domain.point.converter import onku.backend.domain.point.MemberPointHistory -import onku.backend.domain.point.dto.UserPointRecordResponse 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): UserPointRecordResponse = - UserPointRecordResponse( + 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, diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UserPointResponse.kt b/src/main/kotlin/onku/backend/domain/point/dto/UserPointResponse.kt index 1ed314a..b8eb45c 100644 --- a/src/main/kotlin/onku/backend/domain/point/dto/UserPointResponse.kt +++ b/src/main/kotlin/onku/backend/domain/point/dto/UserPointResponse.kt @@ -3,22 +3,22 @@ package onku.backend.domain.point.dto import io.swagger.v3.oas.annotations.media.Schema @Schema(description = "단일 상/벌점 레코드 응답") -data class UserPointRecordResponse( +data class MemberPointHistory( val date: String, val type: String, val points: Int, - val week: Int? = null, + val week: Long? = null, val attendanceTime: String? = null, val earlyLeaveTime: String? = null ) @Schema(description = "사용자 상/벌점 이력 응답") -data class UserPointHistoryResponse( +data class MemberPointHistoryResponse( val memberId: Long, val plusPoints: Int, val minusPoints: Int, val totalPoints: Int, - val records: List, + val records: List, val totalPages: Int, val isLastPage: Boolean ) diff --git a/src/main/kotlin/onku/backend/domain/point/repository/MemberPointRepository.kt b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt similarity index 92% rename from src/main/kotlin/onku/backend/domain/point/repository/MemberPointRepository.kt rename to src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt index 53fd7a0..92fc7f3 100644 --- a/src/main/kotlin/onku/backend/domain/point/repository/MemberPointRepository.kt +++ b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt @@ -7,7 +7,7 @@ import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query -interface MemberPointRepository : JpaRepository { +interface MemberPointHistoryRepository : JpaRepository { fun findByMemberOrderByOccurredAtDesc(member: Member, pageable: Pageable): Page diff --git a/src/main/kotlin/onku/backend/domain/point/service/MemberPointService.kt b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt similarity index 80% rename from src/main/kotlin/onku/backend/domain/point/service/MemberPointService.kt rename to src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt index f9c9980..b35013e 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/MemberPointService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt @@ -2,20 +2,20 @@ package onku.backend.domain.point.service import onku.backend.domain.member.Member import onku.backend.domain.point.converter.MemberPointConverter -import onku.backend.domain.point.dto.UserPointHistoryResponse -import onku.backend.domain.point.repository.MemberPointRepository +import onku.backend.domain.point.dto.MemberPointHistoryResponse +import onku.backend.domain.point.repository.MemberPointHistoryRepository import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import kotlin.math.max @Service -class MemberPointService( - private val recordRepository: MemberPointRepository +class MemberPointHistoryService( + private val recordRepository: MemberPointHistoryRepository ) { @Transactional(readOnly = true) - fun getHistory(member: Member, page1Based: Int, size: Int): UserPointHistoryResponse { + fun getHistory(member: Member, page1Based: Int, size: Int): MemberPointHistoryResponse { val safePage = max(0, page1Based - 1) val pageable = PageRequest.of(safePage, size) @@ -29,7 +29,7 @@ class MemberPointService( val page = recordRepository.findByMemberOrderByOccurredAtDesc(member, pageable) val records = page.content.map(MemberPointConverter::toResponse) - return UserPointHistoryResponse( + return MemberPointHistoryResponse( memberId = member.id!!, plusPoints = plusPoints, minusPoints = minusPoints, From 0d7ae61864c58f2021a4ef7c22f30f8feb949a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 22 Oct 2025 19:24:32 +0900 Subject: [PATCH 191/470] =?UTF-8?q?chore:=20dto=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85=20=EC=88=98=EC=A0=95=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/{UserPointResponse.kt => MemberPointHistoryResponse.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/kotlin/onku/backend/domain/point/dto/{UserPointResponse.kt => MemberPointHistoryResponse.kt} (100%) diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UserPointResponse.kt b/src/main/kotlin/onku/backend/domain/point/dto/MemberPointHistoryResponse.kt similarity index 100% rename from src/main/kotlin/onku/backend/domain/point/dto/UserPointResponse.kt rename to src/main/kotlin/onku/backend/domain/point/dto/MemberPointHistoryResponse.kt From 403435deff40ae3720a133d7d2a0af696e111c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 22 Oct 2025 19:45:37 +0900 Subject: [PATCH 192/470] =?UTF-8?q?feat:=20lateThresholdTime,=20openGraceS?= =?UTF-8?q?econds=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EC=83=81=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EB=8C=80=EC=B2=B4=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/service/AttendanceService.kt | 26 +++++++++++++------ .../session/repository/SessionRepository.kt | 16 +++--------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 3b2d1a0..3e3c5f5 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -10,6 +10,7 @@ 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.global.exception.CustomException import onku.backend.global.exception.ErrorCode @@ -33,6 +34,9 @@ class AttendanceService( private val clock: Clock ) { private val ttlSeconds: Long = 15L + private val OPEN_GRACE_MINUTES: Long = 30 + private val LATE_THRESHOLD_MINUTES: Long = 20 + @Transactional(readOnly = true) fun issueAttendanceTokenFor(member: Member): AttendanceTokenResponse { @@ -44,11 +48,20 @@ class AttendanceService( return AttendanceTokenResponse(token = token, expAt = expAt) } + private fun findOpenSession(now: LocalDateTime): Session? { + val startUpper = now.plusMinutes(OPEN_GRACE_MINUTES) + val endLower = now + return sessionRepository + .findFirstByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDesc( + startUpper, endLower + ) + } + @Transactional fun scanAndRecordBy(admin: Member, token: String): AttendanceResponse { val now = LocalDateTime.now(clock) - val session = sessionRepository.findOpenSession(now) + val session = findOpenSession(now) ?: throw CustomException(AttendanceErrorCode.SESSION_NOT_OPEN) val peek = tokenCache.peek(token) @@ -57,17 +70,14 @@ class AttendanceService( val memberId = peek.memberId val memberName = memberProfileRepository.findById(memberId).orElse(null)?.name ?: "Unknown" - val already = attendanceRepository.existsBySessionIdAndMemberId(session.id!!, memberId) - if (already) { + if (attendanceRepository.existsBySessionIdAndMemberId(session.id!!, memberId)) { throw CustomException(AttendanceErrorCode.ATTENDANCE_ALREADY_RECORDED) } - val consumed = tokenCache.consumeToken(token) - ?: throw CustomException(ErrorCode.UNAUTHORIZED) + tokenCache.consumeToken(token) ?: throw CustomException(ErrorCode.UNAUTHORIZED) - val state = - if (now.isAfter(session.lateThresholdTime)) AttendancePointType.LATE - else AttendancePointType.PRESENT + val lateThreshold = session.startTime.plusMinutes(LATE_THRESHOLD_MINUTES) + val state = if (now.isAfter(lateThreshold)) AttendancePointType.LATE else AttendancePointType.PRESENT try { attendanceRepository.insertOnly( diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index 84722e8..a12593c 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -11,18 +11,10 @@ import java.time.LocalDateTime interface SessionRepository : CrudRepository { - @Query( - value = """ - SELECT * - FROM session s - WHERE :now BETWEEN TIMESTAMPADD(SECOND, -s.open_grace_seconds, s.start_time) - AND TIMESTAMPADD(SECOND, s.close_grace_seconds, s.end_time) - ORDER BY s.start_time DESC - LIMIT 1 - """, - nativeQuery = true - ) - fun findOpenSession(@Param("now") now: LocalDateTime): Session? + fun findFirstByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDesc( + startTimeUpperBound: LocalDateTime, + endTimeLowerBound: LocalDateTime + ): Session? @Query(""" SELECT s From c6197dff82b34252578bd127f439ffe3a50326ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 22 Oct 2025 21:08:31 +0900 Subject: [PATCH 193/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=8B=A4=EA=B1=B4=20=EC=9D=B8=EC=A6=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/SessionManagerController.kt | 2 +- .../dto/request/SessionImageRequest.kt | 5 +++ .../dto/request/UploadSessionImageRequest.kt | 2 +- .../domain/session/facade/SessionFacade.kt | 31 +++++++++++++------ .../session/service/SessionImageService.kt | 14 ++++++--- 5 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/request/SessionImageRequest.kt diff --git a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt index 8a01ea0..f42f845 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt @@ -68,7 +68,7 @@ class SessionManagerController( fun uploadSessionImage( @CurrentMember member: Member, @RequestBody uploadSessionImageRequest : UploadSessionImageRequest - ) : ResponseEntity> { + ) : ResponseEntity>> { return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.uploadSessionImage(member, uploadSessionImageRequest))) } 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/UploadSessionImageRequest.kt b/src/main/kotlin/onku/backend/domain/session/dto/request/UploadSessionImageRequest.kt index 7aa3d86..6dae794 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/request/UploadSessionImageRequest.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/request/UploadSessionImageRequest.kt @@ -4,5 +4,5 @@ import jakarta.validation.constraints.NotNull data class UploadSessionImageRequest ( @field:NotNull val sessionDetailId : Long, - val imageFileName : String + val imageFileName : List ) \ 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 index 79f39a7..d245401 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -55,18 +55,29 @@ class SessionFacade( fun uploadSessionImage( member: Member, uploadSessionImageRequest: UploadSessionImageRequest - ): UploadSessionImageResponse { + ): List { val sessionDetail = sessionDetailService.getById(uploadSessionImageRequest.sessionDetailId) - val getS3UrlDto = s3Service.getPostS3Url( - member.id!!, - uploadSessionImageRequest.imageFileName, - FolderName.SESSION.name - ) - val sessionImage = sessionImageService.uploadImage(getS3UrlDto.key, sessionDetail) - return UploadSessionImageResponse( - sessionImage.id!!, - getS3UrlDto.preSignedUrl + + val preSignedList = uploadSessionImageRequest.imageFileName.map { image -> + val preSign = s3Service.getPostS3Url( + member.id!!, + image.fileName, + FolderName.SESSION.name + ) + 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 { diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt index 4581910..3761fa0 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt @@ -5,6 +5,7 @@ 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 @@ -12,13 +13,18 @@ import org.springframework.transaction.annotation.Transactional class SessionImageService( private val sessionImageRepository: SessionImageRepository, ) { - fun uploadImage(key: String, sessionDetail: SessionDetail) : SessionImage { - return sessionImageRepository.save( + @Transactional + fun uploadImages( + sessionDetail: SessionDetail, + preSignedImages: List + ): List { + val entities = preSignedImages.map { info -> SessionImage( sessionDetail = sessionDetail, - url = key + url = info.key ) - ) + } + return sessionImageRepository.saveAll(entities).toList() } @Transactional From 5237363e3b6da282832b5b6189ac282839061101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 23 Oct 2025 09:14:44 +0900 Subject: [PATCH 194/470] =?UTF-8?q?refactor=20:=20S3=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80,=20=ED=8C=8C=EC=9D=BC=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/absence/facade/AbsenceFacade.kt | 3 ++- .../domain/kupick/facade/KupickFacade.kt | 5 ++-- .../domain/session/facade/SessionFacade.kt | 4 +++- .../global/s3/controller/S3Controller.kt | 3 ++- .../backend/global/s3/enums/UploadOption.kt | 5 ++++ .../backend/global/s3/service/S3Service.kt | 24 +++++++++++++++---- 6 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/onku/backend/global/s3/enums/UploadOption.kt diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index 4d96d63..d5d77f6 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -11,6 +11,7 @@ import onku.backend.global.exception.ErrorCode 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 @@ -36,7 +37,7 @@ class AbsenceFacade( throw CustomException(ErrorCode.INVALID_SESSION) } } - val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name) + val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name, UploadOption.FILE) absenceService.submitAbsenceReport(member, submitAbsenceReportRequest, preSignedUrlDto.key, session) return GetPreSignedUrlDto(preSignedUrlDto.preSignedUrl) } diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index 5b8b27b..9cd3e66 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -8,6 +8,7 @@ import onku.backend.domain.member.Member 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 @@ -18,7 +19,7 @@ class KupickFacade( private val kupickService: KupickService, ) { fun submitApplication(member: Member, fileName: String): GetPreSignedUrlDto { - val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_APPLICATION.name) + val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_APPLICATION.name, UploadOption.IMAGE) kupickService.submitApplication(member, signedUrlDto.key) return GetPreSignedUrlDto( signedUrlDto.preSignedUrl @@ -26,7 +27,7 @@ class KupickFacade( } fun submitView(member: Member, fileName: String): GetPreSignedUrlDto { - val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_VIEW.name) + val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_VIEW.name, UploadOption.IMAGE) kupickService.submitView(member, signedUrlDto.key) return GetPreSignedUrlDto( signedUrlDto.preSignedUrl diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index d245401..0b3f5c5 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -15,6 +15,7 @@ import onku.backend.domain.session.service.SessionService 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 @@ -62,7 +63,8 @@ class SessionFacade( val preSign = s3Service.getPostS3Url( member.id!!, image.fileName, - FolderName.SESSION.name + FolderName.SESSION.name, + UploadOption.IMAGE ) preSign } diff --git a/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt b/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt index ddb9b3e..cdd5611 100644 --- a/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt +++ b/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt @@ -6,6 +6,7 @@ import onku.backend.domain.member.Member import onku.backend.global.annotation.CurrentMember import onku.backend.global.response.SuccessResponse import onku.backend.global.s3.dto.GetS3UrlDto +import onku.backend.global.s3.enums.UploadOption import onku.backend.global.s3.service.S3Service import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -24,7 +25,7 @@ class S3Controller( @GetMapping("/postUrl") @Operation(summary = "업로드 용 postUrl", description = "업로드 용 PresignedUrl을 반환합니다.") fun postUrl(@CurrentMember member : Member, folderName : String, fileName : String) : ResponseEntity> { - return ResponseEntity.ok(SuccessResponse.ok(s3Service.getPostS3Url(member.id!!, fileName, folderName))) + return ResponseEntity.ok(SuccessResponse.ok(s3Service.getPostS3Url(member.id!!, fileName, folderName, UploadOption.FILE))) } @GetMapping("/getUrl") diff --git a/src/main/kotlin/onku/backend/global/s3/enums/UploadOption.kt b/src/main/kotlin/onku/backend/global/s3/enums/UploadOption.kt new file mode 100644 index 0000000..0df4296 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/enums/UploadOption.kt @@ -0,0 +1,5 @@ +package onku.backend.global.s3.enums + +enum class UploadOption { + FILE, IMAGE +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt index ac7ff80..f67b52a 100644 --- a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -3,6 +3,7 @@ package onku.backend.global.s3.service import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode import onku.backend.global.s3.dto.GetS3UrlDto +import onku.backend.global.s3.enums.UploadOption import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -24,9 +25,14 @@ class S3Service( private val s3Presigner: S3Presigner ) { @Transactional(readOnly = true) - fun getPostS3Url(memberId: Long, filename: String, folderName : String): GetS3UrlDto { + fun getPostS3Url(memberId: Long, filename: String, folderName : String, option : UploadOption): GetS3UrlDto { val key = "$folderName/$memberId/${UUID.randomUUID()}/$filename" - val contentType = guessContentType(filename) + val contentType = when (option) { + UploadOption.FILE -> guessFileType(filename) + UploadOption.IMAGE -> guessImageType(filename) + else -> throw IllegalArgumentException("Unsupported upload option: $option") + } + val putObjReq = PutObjectRequest.builder() .bucket(bucket) @@ -47,7 +53,7 @@ class S3Service( @Transactional(readOnly = true) fun getGetS3Url(memberId: Long, key: String): GetS3UrlDto { - val contentType = guessContentType(key) + val contentType = guessFileType(key) // 응답 Content-Type을 강제로 지정하고 싶으면 responseContentType 사용 val getObjReq = GetObjectRequest.builder() @@ -85,7 +91,7 @@ class S3Service( return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key) } - private fun guessContentType(filename: String): String { + private fun guessFileType(filename: String): String { val lower = filename.lowercase() return when { lower.endsWith(".jpg") || lower.endsWith(".jpeg") -> "image/jpeg" @@ -97,6 +103,16 @@ class S3Service( } } + private fun guessImageType(filename: String): String { + val lower = filename.lowercase() + return when { + lower.endsWith(".jpg") || lower.endsWith(".jpeg") -> "image/jpeg" + lower.endsWith(".png") -> "image/png" + lower.endsWith(".heic") -> "image/heic" + else -> throw CustomException(ErrorCode.INVALID_FILE_EXTENSION) + } + } + companion object { private val DEFAULT_EXPIRE: Duration = Duration.ofMinutes(10) } From 69b81f464e42dcb45410c50625baeddd69487bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 23 Oct 2025 09:21:53 +0900 Subject: [PATCH 195/470] =?UTF-8?q?refactor=20:=20=EB=B6=88=EC=B0=B8?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EC=84=9C=20=ED=8C=8C=EC=9D=BC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B8=B0=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../absence/dto/request/SubmitAbsenceReportRequest.kt | 3 +-- .../onku/backend/global/s3/service/S3Service.kt | 11 +++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) 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 index 260b616..808ec2f 100644 --- a/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt +++ b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt @@ -1,6 +1,5 @@ package onku.backend.domain.absence.dto.request -import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import onku.backend.domain.absence.dto.annotation.ValidAbsenceReport import onku.backend.domain.absence.enums.AbsenceType @@ -11,7 +10,7 @@ data class SubmitAbsenceReportRequest( @field:NotNull val sessionId : Long, val absenceType : AbsenceType, val reason : String, - @field:NotBlank val fileName : String, + val fileName : String, val lateDateTime : LocalDateTime?, val leaveDateTime : LocalDateTime? ) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt index f67b52a..69adeed 100644 --- a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -25,7 +25,11 @@ class S3Service( private val s3Presigner: S3Presigner ) { @Transactional(readOnly = true) - fun getPostS3Url(memberId: Long, filename: String, folderName : String, option : UploadOption): GetS3UrlDto { + fun getPostS3Url(memberId: Long, filename: String?, folderName : String, option : UploadOption): GetS3UrlDto { + if(filename.isNullOrBlank()) { + return GetS3UrlDto(preSignedUrl = "", key = "") + } + val key = "$folderName/$memberId/${UUID.randomUUID()}/$filename" val contentType = when (option) { UploadOption.FILE -> guessFileType(filename) @@ -52,7 +56,10 @@ class S3Service( } @Transactional(readOnly = true) - fun getGetS3Url(memberId: Long, key: String): GetS3UrlDto { + fun getGetS3Url(memberId: Long, key: String?): GetS3UrlDto { + if(key.isNullOrBlank()) { + return GetS3UrlDto("", "") + } val contentType = guessFileType(key) // 응답 Content-Type을 강제로 지정하고 싶으면 responseContentType 사용 From 1bfcaa9cad0030312c39e0405ce5e0519ffced2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 23 Oct 2025 09:43:34 +0900 Subject: [PATCH 196/470] =?UTF-8?q?refactor=20:=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=B6=88=EC=B0=B8?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EC=84=9C=20=EA=B3=84=EC=82=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0(=EB=B6=88=EC=B0=B8=EC=82=AC=EC=9C=A0=EC=84=9C=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=EC=9D=BC=20=EC=A7=80=EB=82=98=EB=A9=B4=20fal?= =?UTF-8?q?se)=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/controller/user/SessionController.kt | 9 ++------- .../backend/domain/session/facade/SessionFacade.kt | 6 ++---- .../domain/session/repository/SessionRepository.kt | 3 +-- .../backend/domain/session/service/SessionService.kt | 4 ++-- .../domain/session/validator/SessionValidator.kt | 11 ++++++----- 5 files changed, 13 insertions(+), 20 deletions(-) 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 index a4a5b31..6ab3312 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt @@ -4,12 +4,10 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import onku.backend.domain.session.dto.response.SessionAboutAbsenceResponse import onku.backend.domain.session.facade.SessionFacade -import onku.backend.global.page.PageResponse 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 @@ -24,10 +22,7 @@ class SessionController( description = "불참사유서 제출 페이지에서 세션 정보를 조회합니다." ) fun showSessionAboutAbsence( - @RequestParam(defaultValue = "1") page: Int, - @RequestParam(defaultValue = "5") size: Int - ) : ResponseEntity>> { - val safePage = if (page < 1) 0 else page - 1 - return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showSessionAboutAbsence(safePage, size))) + ) : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showSessionAboutAbsence())) } } \ 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 index 0b3f5c5..004e778 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -27,10 +27,8 @@ class SessionFacade( private val sessionImageService : SessionImageService, private val s3Service: S3Service ) { - fun showSessionAboutAbsence(page: Int, size: Int): PageResponse { - val pageRequest = PageRequest.of(page, size) - val sessionPage = sessionService.getUpcomingSessionsForAbsence(pageRequest) - return PageResponse.from(sessionPage) + fun showSessionAboutAbsence(): List { + return sessionService.getUpcomingSessionsForAbsence() } fun sessionSave(sessionSaveRequestList: List): Boolean { diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index 06cf12d..5a4732e 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -34,9 +34,8 @@ interface SessionRepository : CrudRepository { ) fun findUpcomingSessions( @Param("now") now: LocalDate, - pageable: Pageable, @Param("restCategory") restCategory: SessionCategory = SessionCategory.REST - ): Page + ): List @Query(""" diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index 4c44f8a..0973f76 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -24,10 +24,10 @@ class SessionService( private val clock: Clock = Clock.system(ZoneId.of("Asia/Seoul")) ) { @Transactional(readOnly = true) - fun getUpcomingSessionsForAbsence(pageable: Pageable): Page { + fun getUpcomingSessionsForAbsence(): List { val now = LocalDateTime.now(clock) - val sessions = sessionRepository.findUpcomingSessions(now.toLocalDate(), pageable) + val sessions = sessionRepository.findUpcomingSessions(now.toLocalDate()) return sessions.map { s -> val active = sessionValidator.isImminentSession(s, now) diff --git a/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt b/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt index 15d13fc..2c1f7ee 100644 --- a/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt +++ b/src/main/kotlin/onku/backend/domain/session/validator/SessionValidator.kt @@ -20,13 +20,14 @@ class SessionValidator { /** 금/토에 바로 앞 토요일 세션인지 여부 */ fun isImminentSession(session: Session, now: LocalDateTime = LocalDateTime.now(zone)): Boolean { + val sessionDate = session.startDate // 이미 LocalDate 타입 val today = now.toLocalDate() - val isFriOrSat = today.dayOfWeek == DayOfWeek.FRIDAY || today.dayOfWeek == DayOfWeek.SATURDAY - if (!isFriOrSat) return false - val upcomingSaturday = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) - val sessionDate = session.startDate - return sessionDate == upcomingSaturday + // 세션이 속한 주의 목요일 계산 + val sessionThursday = sessionDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.THURSDAY)) + + // 목요일 00:00부터는 불가 → 목요일보다 이전일 때만 true + return today.isBefore(sessionThursday) } /** 휴회 세션인지 여부 */ From 55adbee940273a1c29aac615320482f9af6b7c2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 23 Oct 2025 11:29:52 +0900 Subject: [PATCH 197/470] =?UTF-8?q?feat=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20api?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/SessionManagerController.kt | 12 +++++++ .../domain/session/dto/SessionImageDto.kt | 10 ++++++ .../dto/response/GetDetailSessionResponse.kt | 20 ++++++++++++ .../domain/session/facade/SessionFacade.kt | 32 ++++++++++++++++--- .../repository/SessionImageRepository.kt | 2 +- .../session/service/SessionImageService.kt | 5 +++ 6 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/SessionImageDto.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/response/GetDetailSessionResponse.kt diff --git a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt index f42f845..17b651d 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt @@ -8,6 +8,7 @@ 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 @@ -83,4 +84,15 @@ class SessionManagerController( 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))) + } + } 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..c376eff --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/SessionImageDto.kt @@ -0,0 +1,10 @@ +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 +) \ No newline at end of file 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..b239dbe --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/GetDetailSessionResponse.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.dto.SessionImageDto +import java.time.LocalTime + +data class GetDetailSessionResponse( + @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 +) diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index 004e778..19b98c5 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -1,14 +1,12 @@ package onku.backend.domain.session.facade import onku.backend.domain.member.Member +import onku.backend.domain.session.dto.SessionImageDto import onku.backend.domain.session.dto.request.DeleteSessionImageRequest -import onku.backend.domain.session.dto.response.SessionAboutAbsenceResponse 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.GetInitialSessionResponse -import onku.backend.domain.session.dto.response.UploadSessionImageResponse -import onku.backend.domain.session.dto.response.UpsertSessionDetailResponse +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.SessionService @@ -88,4 +86,30 @@ class SessionFacade( getS3UrlDto.preSignedUrl ) } + + fun getSessionDetailPage(detailId: Long): GetDetailSessionResponse { + val detail = sessionDetailService.getById(detailId) + val images = sessionImageService.findAllBySessionDetailId(detailId) + + val imageDtos = images.map { image -> + val preSignedUrl = s3Service.getGetS3Url( + memberId = 0, + key = image.url + ).preSignedUrl + + SessionImageDto( + sessionImageId = image.id!!, + sessionImagePreSignedUrl = preSignedUrl + ) + } + + return GetDetailSessionResponse( + sessionDetailId = detail.id!!, + place = detail.place, + startTime = detail.startTime, + endTime = detail.endTime, + content = detail.content, + sessionImages = imageDtos + ) + } } \ 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 index 9263717..dda0a16 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt @@ -4,5 +4,5 @@ import onku.backend.domain.session.SessionImage import org.springframework.data.repository.CrudRepository interface SessionImageRepository : CrudRepository { - + fun findAllBySessionDetailId(sessionDetailId: Long): List } \ 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 index 3761fa0..e0870ec 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionImageService.kt @@ -36,4 +36,9 @@ class SessionImageService( 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 From 796862b7dd80702a048aacd0fc3ae16f3aa9b460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 11:58:15 +0900 Subject: [PATCH 198/470] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=A7=81=20=EC=97=AC=EB=B6=80=EB=A5=BC=20=EC=B6=94=EC=A0=81?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=B4=20Session=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EC=97=90=20=EC=99=84=EB=A3=8C=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EC=A0=80=EC=9E=A5=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/session/Session.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/session/Session.kt b/src/main/kotlin/onku/backend/domain/session/Session.kt index 7bb4e09..097a27d 100644 --- a/src/main/kotlin/onku/backend/domain/session/Session.kt +++ b/src/main/kotlin/onku/backend/domain/session/Session.kt @@ -34,6 +34,9 @@ class Session( @Column(name = "is_reward", nullable = false) val isReward: Boolean, + var attendanceFinalized: Boolean = false, + var attendanceFinalizedAt: LocalDateTime? = null, + @Column(name = "open_grace_seconds", nullable = false) // 세션 시작 이전 N초부터 출석 허용 val openGraceSeconds: Long = 0, From ff65bb8d5f70daa0540c6d5aa362ca87c4cdd9c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 11:59:28 +0900 Subject: [PATCH 199/470] =?UTF-8?q?feat:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=EB=A5=BC=20=EC=84=B8=EC=85=98=EA=B3=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EB=B3=84=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=98=EB=8A=94=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/absence/repository/AbsenceReportRepository.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt index 954201c..4af6c57 100644 --- a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -26,4 +26,13 @@ interface AbsenceReportRepository : JpaRepository { @Param("member") member: Member, pageable: Pageable ): Page + + @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 } \ No newline at end of file From aeb4b57574943c81047364470de5bd00514c9066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 12:00:08 +0900 Subject: [PATCH 200/470] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EB=B3=84?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=B6=9C=EC=84=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/repository/AttendanceRepository.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt index 3bcbe9b..ef2faf2 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt @@ -35,4 +35,10 @@ interface AttendanceRepository : CrudRepository { start: LocalDateTime, end: LocalDateTime ): List + + @Query(""" + select a.memberId from Attendance a + where a.sessionId = :sessionId + """) + fun findMemberIdsBySessionId(@Param("sessionId") sessionId: Long): List } From efcf14641335b0b227e2da119d44d0fba2799e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 12:00:58 +0900 Subject: [PATCH 201/470] =?UTF-8?q?feat:=20Session=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=97=90=20=EC=B6=94=EA=B0=80=ED=95=9C=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=ED=95=84=EB=93=9C=EC=99=80=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=8B=9C=EC=9E=91=EC=8B=9C=EA=B0=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=84=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/repository/SessionRepository.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index a12593c..d4190a0 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -36,4 +36,24 @@ interface SessionRepository : CrudRepository { @Param("start") start: LocalDateTime, @Param("end") end: LocalDateTime ): List + + @Query(""" + SELECT s + FROM Session s + WHERE s.attendanceFinalized = false + AND s.startTime <= :pivot + """) + fun findFinalizeDue( + @Param("pivot") pivot: LocalDateTime + ): List + + @Query(""" + SELECT s + FROM Session s + WHERE s.attendanceFinalized = false + AND s.startTime > :pivot + """) + fun findUnfinalizedAfter( + @Param("pivot") pivot: LocalDateTime + ): List } From df5fa256d661657f5fdf4c8b9e1b3a9f2ff862b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 12:01:46 +0900 Subject: [PATCH 202/470] =?UTF-8?q?feat:=20Member=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=A4=91=20approve=20=EB=90=9C=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=20=EC=A1=B0=ED=9A=8C=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/member/repository/MemberRepository.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt index 79f18a0..11fa851 100644 --- a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt @@ -3,8 +3,15 @@ package onku.backend.domain.member.repository import onku.backend.domain.member.Member 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: Long, 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 } \ No newline at end of file From 0427a49b51605cfb4a72ca53afd3409e09a5d3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 12:02:21 +0900 Subject: [PATCH 203/470] =?UTF-8?q?fix:=20=EC=A7=80=EA=B0=81=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/service/AttendanceService.kt | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 3e3c5f5..f180d0f 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -3,6 +3,7 @@ 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.AttendancePointType import onku.backend.domain.attendance.repository.AttendanceRepository @@ -33,23 +34,24 @@ class AttendanceService( @PersistenceContext private val em: EntityManager, private val clock: Clock ) { - private val ttlSeconds: Long = 15L - private val OPEN_GRACE_MINUTES: Long = 30 - private val LATE_THRESHOLD_MINUTES: Long = 20 - - @Transactional(readOnly = true) fun issueAttendanceTokenFor(member: Member): AttendanceTokenResponse { val now = LocalDateTime.now(clock) - val expAt = now.plusSeconds(ttlSeconds) + val expAt = now.plusSeconds(AttendancePolicy.TOKEN_TTL_SECONDS) val token = tokenGenerator.generateOpaqueToken() - tokenCache.putAsActiveSingle(member.id!!, token, now, expAt, ttlSeconds) + tokenCache.putAsActiveSingle( + member.id!!, + token, + now, + expAt, + AttendancePolicy.TOKEN_TTL_SECONDS + ) return AttendanceTokenResponse(token = token, expAt = expAt) } private fun findOpenSession(now: LocalDateTime): Session? { - val startUpper = now.plusMinutes(OPEN_GRACE_MINUTES) + val startUpper = now.plusMinutes(AttendancePolicy.OPEN_GRACE_MINUTES) val endLower = now return sessionRepository .findFirstByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDesc( @@ -76,8 +78,14 @@ class AttendanceService( tokenCache.consumeToken(token) ?: throw CustomException(ErrorCode.UNAUTHORIZED) - val lateThreshold = session.startTime.plusMinutes(LATE_THRESHOLD_MINUTES) - val state = if (now.isAfter(lateThreshold)) AttendancePointType.LATE else AttendancePointType.PRESENT + val startTime = session.startTime + val lateThreshold = startTime.plusMinutes(AttendancePolicy.LATE_WINDOW_MINUTES) + + val state = when { + now.isAfter(lateThreshold) -> AttendancePointType.ABSENT + !now.isBefore(startTime) -> AttendancePointType.LATE + else -> AttendancePointType.PRESENT + } try { attendanceRepository.insertOnly( @@ -112,4 +120,4 @@ class AttendanceService( scannedAt = now ) } -} +} \ No newline at end of file From 4ae2256e8c055d892808a4b5f2f7f9725e037525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 12:02:55 +0900 Subject: [PATCH 204/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=83=81=EC=88=98=20=EA=B4=80=EB=A6=AC=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/attendance/AttendancePolicy.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/AttendancePolicy.kt 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 +} From 82c7bcdc12165511d8d1aace29abd50dddda3a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 12:03:45 +0900 Subject: [PATCH 205/470] =?UTF-8?q?feat:=20task=20scheduler=20config=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/TaskSchedulerConfig.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/config/TaskSchedulerConfig.kt 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() + } +} From be6ab1180b753abdf328f4c516a48ea3de3d0bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 12:05:57 +0900 Subject: [PATCH 206/470] =?UTF-8?q?feat:=20=EA=B2=B0=EC=84=9D=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=9E=91=EC=97=85=EC=9D=84=20=EC=A7=80=EC=A0=95=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=EC=97=90=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=A7=81=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/finalize/FinalizeScheduler.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeScheduler.kt 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..a1ee7cb --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeScheduler.kt @@ -0,0 +1,21 @@ +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 +) { + fun scheduleOnce(sessionId: Long, runAt: LocalDateTime) { + val instant = runAt.atZone(clock.zone).toInstant() + taskScheduler.schedule({ finalizeService.finalizeSession(sessionId) }, instant) + } +} From f9cb7e4bbe0ea5fbcb0167402c852c1cff934e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 12:23:12 +0900 Subject: [PATCH 207/470] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=EA=BA=BC=EC=A1=8C=EB=8B=A4=EA=B0=80=20=EC=BC=9C=EC=A1=8C?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=EC=9D=98=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=A7=81=20=EC=98=88=EC=95=BD=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/finalize/FinalizeRebuilder.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeRebuilder.kt diff --git a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeRebuilder.kt b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeRebuilder.kt new file mode 100644 index 0000000..a2e64c5 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeRebuilder.kt @@ -0,0 +1,34 @@ +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 org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.LocalDateTime + +@Component +class FinalizeRebuilder( + private val sessionRepository: SessionRepository, + private val oneShot: 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) + // 쿼리에서 startTime만 비교하기 위해 now - 결석 시작 + val pivot = now.minusMinutes(AttendancePolicy.ABSENT_START_MINUTES) + + // 이미 결석 판정 시각을 지난 세션 → 즉시 finalize + sessionRepository.findFinalizeDue(pivot).forEach { s -> + runCatching { attendanceFinalizeService.finalizeSession(s.id!!) } + } + // 아직 결석 판정 시각이 지나지 않은 세션 → 경계 시각으로 재예약 + sessionRepository.findUnfinalizedAfter(pivot).forEach { s -> + val runAt = s.startTime.plusMinutes(AttendancePolicy.ABSENT_START_MINUTES) + oneShot.scheduleOnce(s.id!!, runAt) + } + } +} \ No newline at end of file From ebe9718d76022b263cae435f91ca9dfb654338e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 12:23:59 +0900 Subject: [PATCH 208/470] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=EB=B3=84=20?= =?UTF-8?q?=EB=AF=B8=EC=B6=9C=EC=84=9D=EC=9E=90=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EA=B2=B0=EC=84=9D=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AttendanceFinalizeService.kt | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt 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..1638d27 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt @@ -0,0 +1,90 @@ +package onku.backend.domain.attendance.service + +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import onku.backend.domain.absence.enums.AbsenceReportApproval +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.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.repository.SessionRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.exception.ErrorCode +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(ErrorCode.SESSION_NOT_FOUND) } + if (session.attendanceFinalized) return + + 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!! } + val absentBoundary = session.startTime.plusMinutes(AttendancePolicy.ABSENT_START_MINUTES) + + missing.forEach { memberId -> + val status = mapApprovalToStatus(papers[memberId]?.approval) + 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 mapApprovalToStatus(approval: AbsenceReportApproval?): AttendancePointType = + when (approval) { + AbsenceReportApproval.APPROVED -> AttendancePointType.EXCUSED + AbsenceReportApproval.SUBMIT -> AttendancePointType.ABSENT_WITH_DOC + null -> AttendancePointType.ABSENT + } + + private fun markFinalized(session: Session, now: LocalDateTime) { + session.attendanceFinalized = true + session.attendanceFinalizedAt = now + } +} From 817ff16f03d0bb306bd28b515157b57e369cae9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 23 Oct 2025 14:02:52 +0900 Subject: [PATCH 209/470] =?UTF-8?q?chore=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EA=B0=80=EB=A6=AC=EA=B8=B0=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/global/s3/controller/S3Controller.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt b/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt index cdd5611..ec03caa 100644 --- a/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt +++ b/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt @@ -1,5 +1,6 @@ package onku.backend.global.s3.controller +import io.swagger.v3.oas.annotations.Hidden import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import onku.backend.domain.member.Member @@ -22,12 +23,14 @@ class S3Controller( /** * Todo 테스트용 컨트롤러임 나중에 지우기 */ + @Hidden @GetMapping("/postUrl") @Operation(summary = "업로드 용 postUrl", description = "업로드 용 PresignedUrl을 반환합니다.") fun postUrl(@CurrentMember member : Member, folderName : String, fileName : String) : ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(s3Service.getPostS3Url(member.id!!, fileName, folderName, UploadOption.FILE))) } + @Hidden @GetMapping("/getUrl") @Operation(summary = "조회 용 getUrl", description = "조회 용 PresignedUrl을 반환합니다.") fun getUrl(@CurrentMember member : Member, keyName: String) : ResponseEntity> { From e94371596fbfefd7a8ae91b9f491cb3c582f7d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 18:14:03 +0900 Subject: [PATCH 210/470] =?UTF-8?q?feat:=20Session=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=A0=95=EA=B7=9C=ED=99=94=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20DateTime=EC=9D=84=20=EC=9E=85=EB=A0=A5=EB=B0=9B?= =?UTF-8?q?=EC=95=84=20=EA=B3=84=EC=82=B0=ED=95=98=EB=8D=98=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=93=A4=20=EC=88=98=EC=A0=95=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/service/AttendanceService.kt | 12 ++------- .../backend/domain/session/SessionDetail.kt | 1 - .../session/repository/SessionRepository.kt | 26 ++++++++++++------- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index d19b675..9951afb 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -52,16 +52,8 @@ class AttendanceService( } private fun findOpenSession(now: LocalDateTime): Session? { - val date = now.toLocalDate() - val startUpperTime = now.plusMinutes(AttendancePolicy.OPEN_GRACE_MINUTES).toLocalTime() - val endLowerTime = now.toLocalTime() - - return sessionRepository - .findTopByStartDateAndSessionDetail_StartTimeLessThanEqualAndSessionDetail_EndTimeGreaterThanEqualOrderBySessionDetail_StartTimeDesc( - date, - startUpperTime, - endLowerTime - ) + val startBound = now.plusMinutes(AttendancePolicy.OPEN_GRACE_MINUTES) + return sessionRepository.findOpenWindow(startBound, now).firstOrNull() } @Transactional diff --git a/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt b/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt index 2ec79a5..900a8a7 100644 --- a/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt +++ b/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt @@ -2,7 +2,6 @@ package onku.backend.domain.session import jakarta.persistence.* import onku.backend.global.entity.BaseEntity -import java.time.LocalDateTime import java.time.LocalTime @Entity diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index bd5e054..1df1b93 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -9,15 +9,21 @@ 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 { - fun findTopByStartDateAndSessionDetail_StartTimeLessThanEqualAndSessionDetail_EndTimeGreaterThanEqualOrderBySessionDetail_StartTimeDesc( - date: LocalDate, - time1: LocalTime, - time2: LocalTime - ): Session? + @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( """ @@ -39,10 +45,10 @@ interface SessionRepository : CrudRepository { fun findAll(pageable: Pageable): Page @Query(""" - select function('timestamp', sess.startDate, d.startTime) - from Session sess join sess.sessionDetail d - where function('timestamp', sess.startDate, d.startTime) >= :start - and function('timestamp', sess.startDate, d.startTime) < :end + SELECT function('timestamp', sess.startDate, d.startTime) + FROM Session sess join sess.sessionDetail d + WHERE function('timestamp', sess.startDate, d.startTime) >= :start + AND function('timestamp', sess.startDate, d.startTime) < :end """) fun findStartTimesBetween( @Param("start") start: LocalDateTime, From 57337737143f3fe460cffc8a350e378a3032b585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 19:26:29 +0900 Subject: [PATCH 211/470] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=8B=9C=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=A7=81=EA=B3=BC=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20publish=20=EC=8B=9C=20=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=B4=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=AA=85=20=EC=88=98=EC=A0=95=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...izeRebuilder.kt => FinalizeBootTrigger.kt} | 6 ++-- .../attendance/finalize/FinalizeEvent.kt | 12 ++++++++ .../finalize/FinalizeEventTrigger.kt | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) rename src/main/kotlin/onku/backend/domain/attendance/finalize/{FinalizeRebuilder.kt => FinalizeBootTrigger.kt} (91%) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEvent.kt create mode 100644 src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEventTrigger.kt diff --git a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeRebuilder.kt b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeBootTrigger.kt similarity index 91% rename from src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeRebuilder.kt rename to src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeBootTrigger.kt index 2d5be64..f87794e 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeRebuilder.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeBootTrigger.kt @@ -10,9 +10,9 @@ import java.time.Clock import java.time.LocalDateTime @Component -class FinalizeRebuilder( +class FinalizeBootTrigger( private val sessionRepository: SessionRepository, - private val oneShot: FinalizeScheduler, + private val finalizeScheduler: FinalizeScheduler, private val attendanceFinalizeService: AttendanceFinalizeService, private val clock: Clock ) { @@ -30,7 +30,7 @@ class FinalizeRebuilder( sessionRepository.findUnfinalizedAfter(pivot).forEach { s -> runCatching { val runAt = SessionTimeUtil.absentBoundary(s, AttendancePolicy.ABSENT_START_MINUTES) - oneShot.scheduleOnce(s.id!!, runAt) + 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..e1047a9 --- /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 FinalizeScheduleEvent( + 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..d3c09a4 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEventTrigger.kt @@ -0,0 +1,28 @@ +package onku.backend.domain.attendance.finalize + +import onku.backend.domain.attendance.dto.FinalizeScheduleEvent +import onku.backend.domain.attendance.service.AttendanceFinalizeService +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.LocalDateTime + +@Component +class SessionFinalizeScheduleListener( + private val finalizeScheduler: FinalizeScheduler, + private val clock: Clock, + private val attendanceFinalizeService: AttendanceFinalizeService +) { + @org.springframework.transaction.event.TransactionalEventListener( + classes = [FinalizeScheduleEvent::class], + phase = org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT + ) + fun onSessionFinalizeSchedule(event: FinalizeScheduleEvent) { + val now = LocalDateTime.now(clock) + + if (!event.runAt.isAfter(now)) { + runCatching { attendanceFinalizeService.finalizeSession(event.sessionId) } + return + } + finalizeScheduler.schedule(event.sessionId, event.runAt) + } +} From 88e0a04ea7d6f45bc6f96893e4b838470e462302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 19:28:11 +0900 Subject: [PATCH 212/470] =?UTF-8?q?feat:=20Session=20upsert=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=EA=B0=80=20=EB=B0=9C=ED=96=89?= =?UTF-8?q?=EB=90=A0=20=EB=95=8C=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=EB=90=A0=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/attendance/finalize/FinalizeEvent.kt | 2 +- .../domain/attendance/finalize/FinalizeEventTrigger.kt | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEvent.kt b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEvent.kt index e1047a9..2d02a6e 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEvent.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEvent.kt @@ -6,7 +6,7 @@ import java.time.LocalDateTime * - sessionId: 마감할 세션 PK * - runAt : 결석 판정 경계 시각(= 이때 finalize 실행) */ -data class FinalizeScheduleEvent( +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 index d3c09a4..a217d3f 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEventTrigger.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeEventTrigger.kt @@ -1,22 +1,21 @@ package onku.backend.domain.attendance.finalize -import onku.backend.domain.attendance.dto.FinalizeScheduleEvent import onku.backend.domain.attendance.service.AttendanceFinalizeService import org.springframework.stereotype.Component import java.time.Clock import java.time.LocalDateTime @Component -class SessionFinalizeScheduleListener( +class FinalizeEventTrigger( private val finalizeScheduler: FinalizeScheduler, private val clock: Clock, private val attendanceFinalizeService: AttendanceFinalizeService ) { @org.springframework.transaction.event.TransactionalEventListener( - classes = [FinalizeScheduleEvent::class], + classes = [FinalizeEvent::class], phase = org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT ) - fun onSessionFinalizeSchedule(event: FinalizeScheduleEvent) { + fun onSessionFinalizeSchedule(event: FinalizeEvent) { val now = LocalDateTime.now(clock) if (!event.runAt.isAfter(now)) { From ac1afd90d6988d72ca5fcc444e6447dd387074cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 19:29:01 +0900 Subject: [PATCH 213/470] =?UTF-8?q?feat:=20SessionDetail=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=EB=8A=94=20=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=EC=9A=B4=20=EC=8B=9C=EA=B0=84=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=B0=EC=84=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/finalize/FinalizeScheduler.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeScheduler.kt b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeScheduler.kt index a1ee7cb..6804ca0 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeScheduler.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeScheduler.kt @@ -14,8 +14,16 @@ class FinalizeScheduler( private val finalizeService: AttendanceFinalizeService, private val clock: Clock ) { - fun scheduleOnce(sessionId: Long, runAt: LocalDateTime) { + 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() - taskScheduler.schedule({ finalizeService.finalizeSession(sessionId) }, instant) + val future = taskScheduler.schedule( + { finalizeService.finalizeSession(sessionId) }, + instant + ) + if (future != null) registry[sessionId] = future } } From 3b44f41bcf27f785a95bdccd8b43c2cec43d8c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 19:30:24 +0900 Subject: [PATCH 214/470] =?UTF-8?q?feat:=20Session=20upsert=20=EC=8B=9C=20?= =?UTF-8?q?FinalizeEvent=20=EB=B0=9C=ED=96=89=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/service/SessionDetailService.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt index 245a65f..9396385 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt @@ -1,5 +1,7 @@ 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 @@ -7,13 +9,17 @@ 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 +import onku.backend.global.exception.ErrorCode @Service class SessionDetailService( private val sessionDetailRepository: SessionDetailRepository, - private val sessionRepository: SessionRepository + private val sessionRepository: SessionRepository, + private val applicationEventPublisher: ApplicationEventPublisher ) { @Transactional fun upsertSessionDetail(session : Session, upsertSessionDetailRequest: UpsertSessionDetailRequest): Long { @@ -38,6 +44,14 @@ class SessionDetailService( session.sessionDetail = detail sessionRepository.save(session) + + val runAt = absentBoundary(session, ABSENT_START_MINUTES) + val sessionId = session.id ?: throw CustomException(ErrorCode.SESSION_NOT_FOUND) + + applicationEventPublisher.publishEvent( + FinalizeEvent(sessionId, runAt) + ) + return (detail.id ?: 0L) } From 221c192271d74b1ddb105b6c52ae0eb37a2238a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 19:51:02 +0900 Subject: [PATCH 215/470] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EC=88=98=EC=A0=95=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80=20#44?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/point/controller/AdminPointController.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt index 28d262a..34623de 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt @@ -65,6 +65,14 @@ class AdminPointController( return ResponseEntity.ok(SuccessResponse.ok(Unit)) } + @PatchMapping("/is-staff") + @Operation(summary = "운영진 여부 수정", description = "memberId와 isStaff를 받아 Member의 isStaff를 수정합니다.") + fun updateIsStaff(@RequestBody @Valid req: UpdateIsStaffRequest): ResponseEntity> { + commandService.updateIsStaff(req.memberId!!, req.isStaff!!) + return ResponseEntity.ok(SuccessResponse.ok(Unit)) + } + + @PatchMapping("/kupick") @Operation( summary = "이번 달 큐픽 존재 여부 수정", From f8108f1e0b221a94acdce0a76c3da6d08b99b730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 19:51:30 +0900 Subject: [PATCH 216/470] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EC=88=98=EC=A0=95=20dto=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#44?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/point/dto/UpdateManualPointRequest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt index bb64988..3bbf2a7 100644 --- a/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt @@ -10,6 +10,11 @@ data class UpdateIsTfRequest( @field:NotNull val isTf: Boolean? ) : UpdateManualPointRequest +data class UpdateIsStaffRequest( + @field:NotNull val memberId: Long? = null, + @field:NotNull val isStaff: Boolean? = null +) + data class UpdateKupickRequest( @field:NotNull val memberId: Long?, @field:NotNull val isKupick: Boolean? From b2e822f8ae9f27d64f5f3e4681ed5586138eb8aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 19:53:23 +0900 Subject: [PATCH 217/470] =?UTF-8?q?feat:=20TF,=20STAFF,=20KUPICK,=20KUPORT?= =?UTF-8?q?ERS,=20STUDY=20=EC=A0=90=EC=88=98=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=8B=9C=20MemberPointHistory=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20#44?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/point/MemberPointHistory.kt | 5 +- .../point/service/AdminPointCommandService.kt | 87 +++++++++++++++++-- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt index 978d019..745b11c 100644 --- a/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt +++ b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt @@ -95,13 +95,14 @@ class MemberPointHistory( fun ofManual( member: Member, manualType: ManualPointType, - occurredAt: LocalDateTime + occurredAt: LocalDateTime, + points: Int ): MemberPointHistory { return MemberPointHistory( member = member, category = PointCategory.MANUAL, type = manualType.name, - points = manualType.points, + points = points, occurredAt = occurredAt ) } diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt index e7d8a06..334cb53 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt @@ -6,7 +6,10 @@ 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.enums.ManualPointType import onku.backend.domain.point.repository.ManualPointRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository import onku.backend.global.exception.CustomException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -19,20 +22,49 @@ class AdminPointCommandService( private val manualPointRecordRepository: ManualPointRepository, private val memberRepository: MemberRepository, private val kupickRepository: KupickRepository, + private val memberPointHistoryRepository: MemberPointHistoryRepository, private val clock: Clock ) { @Transactional fun updateStudyPoints(memberId: Long, studyPoints: Int) { val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) - rec.studyPoints = studyPoints + val before = rec.studyPoints ?: 0 + val after = studyPoints + val delta = after - before + if (delta != 0) { + val now = LocalDateTime.now(clock) + memberPointHistoryRepository.save( + MemberPointHistory.ofManual( + member = rec.member, + manualType = ManualPointType.STUDY, + occurredAt = now, + points = delta + ) + ) + } + rec.studyPoints = after manualPointRecordRepository.save(rec) } @Transactional fun updateKupportersPoints(memberId: Long, kuportersPoints: Int) { val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) - rec.kupportersPoints = kuportersPoints + val before = rec.kupportersPoints ?: 0 + val after = kuportersPoints + val delta = after - before + if (delta != 0) { + val now = LocalDateTime.now(clock) + memberPointHistoryRepository.save( + MemberPointHistory.ofManual( + member = rec.member, + manualType = ManualPointType.KUPORTERS, + occurredAt = now, + points = delta + ) + ) + } + rec.kupportersPoints = after manualPointRecordRepository.save(rec) } @@ -47,14 +79,45 @@ class AdminPointCommandService( fun updateIsTf(memberId: Long, isTf: Boolean) { val member = memberRepository.findById(memberId) .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } - member.isTf = isTf + + if (member.isTf != isTf) { + val now = LocalDateTime.now(clock) + val delta = if (isTf) ManualPointType.TF.points else -ManualPointType.TF.points + memberPointHistoryRepository.save( + MemberPointHistory.ofManual( + member = member, + manualType = ManualPointType.TF, + occurredAt = now, + points = delta + ) + ) + member.isTf = isTf + } + } + + @Transactional + fun updateIsStaff(memberId: Long, isStaff: Boolean) { + val member = memberRepository.findById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + if (member.isStaff != isStaff) { + val now = LocalDateTime.now(clock) + val delta = if (isStaff) ManualPointType.STAFF.points else -ManualPointType.STAFF.points + memberPointHistoryRepository.save( + MemberPointHistory.ofManual( + member = member, + manualType = ManualPointType.STAFF, + occurredAt = now, + points = delta + ) + ) + member.isStaff = isStaff + } } private fun newManualRecord(memberId: Long): ManualPoint { val memberRef = runCatching { memberRepository.getReferenceById(memberId) } - .getOrElse { - throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) - } + .getOrElse { throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } return ManualPoint( member = memberRef, studyPoints = 0, @@ -85,7 +148,17 @@ class AdminPointCommandService( target.updateApproval(isKupick) + val points = if (isKupick) ManualPointType.KUPICK.points else -ManualPointType.KUPICK.points + memberPointHistoryRepository.save( + MemberPointHistory.ofManual( + member = member, + manualType = ManualPointType.KUPICK, + occurredAt = now, + points = points + ) + ) + return kupickRepository.save(target).id ?: throw CustomException(KupickErrorCode.KUPICK_SAVE_FAILED) } -} +} \ No newline at end of file From 754fdf782f815b4ecb434751f110fbbb40da67df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 23 Oct 2025 20:22:51 +0900 Subject: [PATCH 218/470] =?UTF-8?q?feat:=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EB=8B=A8=EC=97=90=EC=84=9C=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=83=81=ED=83=9C=EC=9D=98=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=9D=84=20=EC=A4=91=EB=B3=B5=EC=9C=BC=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EB=8A=94=20=EA=B2=83=EC=9D=84=20=EB=A7=89=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=EC=9A=94=EC=B2=AD=20=ED=98=95=ED=83=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#44?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/kupick/Kupick.kt | 2 +- .../point/controller/AdminPointController.kt | 60 +++++----- .../point/dto/UpdateManualPointRequest.kt | 15 +-- .../point/dto/UpdateManualPointResponse.kt | 40 +++++++ .../point/service/AdminPointCommandService.kt | 103 ++++++++++-------- 5 files changed, 133 insertions(+), 87 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointResponse.kt diff --git a/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt index d5d48d7..11bb50e 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt @@ -55,7 +55,7 @@ class Kupick( member = member, submitDate = nowDate, applicationImageUrl = "null", - applicationDate = null, + applicationDate = nowDate, viewImageUrl = null, viewDate = null, approval = false diff --git a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt index 34623de..0c8fbfb 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt @@ -39,51 +39,55 @@ class AdminPointController( @PatchMapping("/study") @Operation(summary = "스터디 점수 수정", description = "memberId와 studyPoints를 받아 수정합니다.") - fun updateStudyPoints(@RequestBody @Valid req: UpdateStudyPointsRequest): ResponseEntity> { - commandService.updateStudyPoints(req.memberId!!, req.studyPoints!!) - return ResponseEntity.ok(SuccessResponse.ok(Unit)) + 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와 kuportersPoints를 받아 수정합니다.") - fun updateKupportersPoints(@RequestBody @Valid req: UpdateKupportersPointsRequest): ResponseEntity> { - commandService.updateKupportersPoints(req.memberId!!, req.kuportersPoints!!) - return ResponseEntity.ok(SuccessResponse.ok(Unit)) + @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> { - commandService.updateMemo(req.memberId!!, req.memo!!) - return ResponseEntity.ok(SuccessResponse.ok(Unit)) + 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와 isTf를 받아 Member의 isTf를 수정합니다.") - fun updateIsTf(@RequestBody @Valid req: UpdateIsTfRequest): ResponseEntity> { - commandService.updateIsTf(req.memberId!!, req.isTf!!) - return ResponseEntity.ok(SuccessResponse.ok(Unit)) + @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 = "운영진 여부 수정", description = "memberId와 isStaff를 받아 Member의 isStaff를 수정합니다.") - fun updateIsStaff(@RequestBody @Valid req: UpdateIsStaffRequest): ResponseEntity> { - commandService.updateIsStaff(req.memberId!!, req.isStaff!!) - return ResponseEntity.ok(SuccessResponse.ok(Unit)) + @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, isKupick(boolean)을 받아 이번 달 큐픽 레코드를 승인/미승인 처리합니다." + - "이번 달 제출 레코드가 없으면 먼저 생성한 뒤 동일하게 approval을 갱신합니다." + summary = "이번 달 큐픽 승인 토글", + description = "memberId만 받아 이번 달 큐픽 승인 여부를 반전시킵니다. 제출 레코드가 없으면 생성합니다." ) - fun updateKupick( - @RequestBody @Valid req: UpdateKupickRequest - ): ResponseEntity> { - commandService.updateKupickApproval(req.memberId!!, req.isKupick!!) - return ResponseEntity.ok(SuccessResponse.ok(Unit)) + fun updateKupick(@RequestBody @Valid req: ToggleMemberRequest) + : ResponseEntity> { + val result = commandService.updateKupickApproval(req.memberId!!) + return ResponseEntity.ok(SuccessResponse.ok(result)) } @GetMapping("/monthly") @@ -101,4 +105,4 @@ class AdminPointController( val body = adminPointsService.getMonthlyPaged(year, month, safePage, size) return ResponseEntity.ok(SuccessResponse.ok(body)) } -} +} \ 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 index 3bbf2a7..c097c7f 100644 --- a/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt @@ -5,21 +5,10 @@ import jakarta.validation.constraints.NotNull sealed interface UpdateManualPointRequest -data class UpdateIsTfRequest( - @field:NotNull val memberId: Long?, - @field:NotNull val isTf: Boolean? -) : UpdateManualPointRequest - -data class UpdateIsStaffRequest( - @field:NotNull val memberId: Long? = null, - @field:NotNull val isStaff: Boolean? = null +data class ToggleMemberRequest( + @field:NotNull val memberId: Long? = null ) -data class UpdateKupickRequest( - @field:NotNull val memberId: Long?, - @field:NotNull val isKupick: Boolean? -) : UpdateManualPointRequest - data class UpdateKupportersPointsRequest( @field:NotNull val memberId: Long?, @field:NotNull val kuportersPoints: Int? 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/service/AdminPointCommandService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt index 334cb53..c9bf6de 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt @@ -7,6 +7,7 @@ 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 @@ -27,7 +28,7 @@ class AdminPointCommandService( ) { @Transactional - fun updateStudyPoints(memberId: Long, studyPoints: Int) { + fun updateStudyPoints(memberId: Long, studyPoints: Int): StudyPointsResult { val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) val before = rec.studyPoints ?: 0 val after = studyPoints @@ -45,13 +46,14 @@ class AdminPointCommandService( } rec.studyPoints = after manualPointRecordRepository.save(rec) + return StudyPointsResult(memberId = rec.member.id!!, studyPoints = after) } @Transactional - fun updateKupportersPoints(memberId: Long, kuportersPoints: Int) { + fun updateKupportersPoints(memberId: Long, kupportersPoints: Int): KupportersPointsResult { val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) val before = rec.kupportersPoints ?: 0 - val after = kuportersPoints + val after = kupportersPoints val delta = after - before if (delta != 0) { val now = LocalDateTime.now(clock) @@ -66,68 +68,61 @@ class AdminPointCommandService( } rec.kupportersPoints = after manualPointRecordRepository.save(rec) + return KupportersPointsResult(memberId = rec.member.id!!, kupportersPoints = after) } @Transactional - fun updateMemo(memberId: Long, memo: String) { + 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, isTf: Boolean) { + fun updateIsTf(memberId: Long): Boolean { val member = memberRepository.findById(memberId) .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } - if (member.isTf != isTf) { - val now = LocalDateTime.now(clock) - val delta = if (isTf) ManualPointType.TF.points else -ManualPointType.TF.points - memberPointHistoryRepository.save( - MemberPointHistory.ofManual( - member = member, - manualType = ManualPointType.TF, - occurredAt = now, - points = delta - ) + 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 = isTf - } + ) + member.isTf = newValue + return newValue } @Transactional - fun updateIsStaff(memberId: Long, isStaff: Boolean) { + fun updateIsStaff(memberId: Long): Boolean { val member = memberRepository.findById(memberId) .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } - if (member.isStaff != isStaff) { - val now = LocalDateTime.now(clock) - val delta = if (isStaff) ManualPointType.STAFF.points else -ManualPointType.STAFF.points - memberPointHistoryRepository.save( - MemberPointHistory.ofManual( - member = member, - manualType = ManualPointType.STAFF, - occurredAt = now, - points = delta - ) - ) - member.isStaff = isStaff - } - } + val now = LocalDateTime.now(clock) + val newValue = !member.isStaff + val delta = if (newValue) ManualPointType.STAFF.points else -ManualPointType.STAFF.points - 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 + memberPointHistoryRepository.save( + MemberPointHistory.ofManual( + member = member, + manualType = ManualPointType.STAFF, + occurredAt = now, + points = delta + ) ) + member.isStaff = newValue + return newValue } @Transactional - fun updateKupickApproval(memberId: Long, isKupick: Boolean): Long { + fun updateKupickApproval(memberId: Long): KupickApprovalResult { val member = memberRepository.findById(memberId) .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } @@ -146,19 +141,37 @@ class AdminPointCommandService( kupickRepository.save(created) } - target.updateApproval(isKupick) + val newApproved = !target.approval + target.updateApproval(newApproved) - val points = if (isKupick) ManualPointType.KUPICK.points else -ManualPointType.KUPICK.points + val delta = if (newApproved) ManualPointType.KUPICK.points else -ManualPointType.KUPICK.points memberPointHistoryRepository.save( MemberPointHistory.ofManual( member = member, manualType = ManualPointType.KUPICK, occurredAt = now, - points = points + points = delta ) ) - return kupickRepository.save(target).id + 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 + ) } } \ No newline at end of file From 878824aa35cb31e92f661d81588b00ec16e69f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 18:26:51 +0900 Subject: [PATCH 219/470] =?UTF-8?q?refactor:=20page=20=EA=B0=92=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/point/controller/MemberPointController.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt b/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt index 3ad21f3..772bd51 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt @@ -32,7 +32,8 @@ class MemberPointController( @RequestParam(defaultValue = "1") page: Int, @RequestParam(defaultValue = "10") size: Int ): ResponseEntity> { - val body = memberPointService.getHistory(member, page, size) + val safePage = if (page < 1) 0 else page - 1 + val body = memberPointService.getHistory(member, safePage, size) return ResponseEntity.ok(SuccessResponse.ok(body)) } } From f6ecb4e8948046921abf5fc1a7535e52d2cec1ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 19:58:04 +0900 Subject: [PATCH 220/470] =?UTF-8?q?feat:=20dto=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B6=94=EA=B0=80=20#48?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/member/dto/OnboardingRequest.kt | 9 ++++++++- .../domain/member/dto/UpdateProfileImageRequest.kt | 12 ++++++++++++ .../domain/member/dto/UpdateProfileImageResponse.kt | 8 ++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/UpdateProfileImageRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/UpdateProfileImageResponse.kt diff --git a/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt index ebc6441..cf40f32 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt @@ -1,5 +1,6 @@ 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 @@ -8,5 +9,11 @@ data class OnboardingRequest ( @field:NotBlank val school: String, @field:NotBlank val major: String, val part: Part, - val phoneNumber: String? = null + val phoneNumber: String? = null, + + @Schema(description = "FCM 토큰", example = "eYJhbGciOi...") + val fcmToken: String? = null, + + @Schema(description = "프로필 이미지 URL", example = "https://s3.../member_profile/1/uuid/profile.png") + val profileImage: String? = null ) 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 +) From 7d4f90c2be6d5fb3218b07758e5889552673073f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 19:59:51 +0900 Subject: [PATCH 221/470] =?UTF-8?q?feat:=20Member=EC=97=90=20fcm=20token?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80,=20MemberProfile=EC=97=90=20profile=20ima?= =?UTF-8?q?ge=20=EC=B6=94=EA=B0=80=20#48?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/member/Member.kt | 8 +++++++- .../kotlin/onku/backend/domain/member/MemberProfile.kt | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/Member.kt b/src/main/kotlin/onku/backend/domain/member/Member.kt index c41e588..97c981e 100644 --- a/src/main/kotlin/onku/backend/domain/member/Member.kt +++ b/src/main/kotlin/onku/backend/domain/member/Member.kt @@ -37,7 +37,10 @@ class Member( var isTf: Boolean = false, @Column(name = "is_staff", nullable = false) - var isStaff: Boolean = false + var isStaff: Boolean = false, + + @Column(name = "fcm_token") + var fcmToken: String? = null ) : BaseEntity() { fun approve() { this.approval = ApprovalStatus.APPROVED @@ -49,4 +52,7 @@ class Member( 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/MemberProfile.kt b/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt index 3ee53d3..8e17857 100644 --- a/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt +++ b/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt @@ -28,7 +28,10 @@ class MemberProfile( var part: Part, @Column(name = "phone_number", length = 30) - var phoneNumber: String? = null + var phoneNumber: String? = null, + + @Column(name = "profile_image", length = 2048) + var profileImage: String? = null ) { fun apply( @@ -36,12 +39,14 @@ class MemberProfile( school: String?, major: String?, part: Part, - phoneNumber: String? + phoneNumber: String?, + profileImage: String? ) { this.name = name this.school = school this.major = major this.part = part this.phoneNumber = phoneNumber + this.profileImage = profileImage } } From e6484612e162fcdb3e54152c545de26defdbd766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:00:47 +0900 Subject: [PATCH 222/470] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=EC=9A=A9=20PresignedURL=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=A5=BC=20=EC=9C=84=ED=95=9C=20enum=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#48?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt index 214e9a1..1a92566 100644 --- a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt +++ b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt @@ -4,5 +4,6 @@ enum class FolderName{ KUPICK_APPLICATION, KUPICK_VIEW, ABSENCE, - SESSION + SESSION, + MEMBER_PROFILE } \ No newline at end of file From b87ab70cef35e1c73435af94bb3e1ee318ed9cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:01:50 +0900 Subject: [PATCH 223/470] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=20=EC=98=A8=EB=B3=B4=EB=94=A9=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=EC=97=AC=EB=B6=80=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20#48?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/dto/AuthLoginResult.kt | 1 + .../global/auth/service/AuthServiceImpl.kt | 47 +++++++++++++++---- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt b/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt index 4ccf9f1..1317c1c 100644 --- a/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt +++ b/src/main/kotlin/onku/backend/global/auth/dto/AuthLoginResult.kt @@ -7,4 +7,5 @@ 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/service/AuthServiceImpl.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt index b7ca9ce..3702fde 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -1,13 +1,11 @@ package onku.backend.global.auth.service -import onku.backend.domain.member.Member 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.service.MemberService import onku.backend.global.auth.AuthErrorCode -import onku.backend.global.auth.dto.* +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.exception.CustomException import onku.backend.global.redis.cache.RefreshTokenCache @@ -66,34 +64,63 @@ class AuthServiceImpl( AuthLoginResult( status = ApprovalStatus.APPROVED, memberId = member.id, - role = member.role + role = member.role, + hasInfo = member.hasInfo ) ) ) } ApprovalStatus.PENDING -> { - if (member.hasInfo) { // 이미 프로필이 있으면 온보딩 토큰 미발급 + if (member.hasInfo) { + // 온보딩 제출 완료(프로필 있음) → 온보딩 토큰 미발급 ResponseEntity .status(HttpStatus.ACCEPTED) - .body(SuccessResponse.ok(AuthLoginResult(status = ApprovalStatus.PENDING))) + .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))) + .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))) + .body( + SuccessResponse.ok( + AuthLoginResult( + status = ApprovalStatus.REJECTED, + memberId = member.id, + role = member.role, + hasInfo = member.hasInfo + ) + ) + ) } } } From 4672ad718d5aa913f20a12c4516fb5d881c63477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:02:32 +0900 Subject: [PATCH 224/470] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80,=20fcm=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#48?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.kt | 51 ++++++++++++++++--- .../member/service/MemberProfileService.kt | 28 +++++++--- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt index 31e58c1..6116902 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt @@ -4,27 +4,31 @@ 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.MemberProfileResponse -import onku.backend.domain.member.dto.OnboardingRequest -import onku.backend.domain.member.dto.OnboardingResponse +import onku.backend.domain.member.dto.* import onku.backend.domain.member.service.MemberProfileService import onku.backend.global.annotation.CurrentMember import onku.backend.global.response.SuccessResponse +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.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/members") -@Tag(name = "회원 API", description = "온보딩 관련 API") +@Tag(name = "회원 API", description = "온보딩 및 프로필 관련 API") class MemberController( - private val memberProfileService: MemberProfileService + private val memberProfileService: MemberProfileService, + private val s3Service: S3Service ) { @PostMapping("/onboarding") @ResponseStatus(HttpStatus.ACCEPTED) @Operation( summary = "온보딩 정보 제출", - description = "소셜 로그인 시도 후 회원가입이 되지 않았고 온보딩을 완료하지 않은 회원에게 발급되는 온보딩 전용 토큰으로 접근 가능." + description = "온보딩 전용 토큰으로 접근" ) fun submitOnboarding( @CurrentMember member: Member, @@ -37,12 +41,43 @@ class MemberController( @GetMapping("/profile/summary") @Operation( summary = "내 프로필 요약 조회", - description = "[TEMP] 현재 로그인한 회원의 이름(name)과 파트(part)만 반환합니다." + description = "[TEMP] 현재 로그인한 회원의 이름(name)과 파트(part)만 반환" ) - fun getMyProfileSummary( // TODO: DisciplinaryRecord 테이블 생성 후 상벌점도 반환 + fun getMyProfileSummary( @CurrentMember member: Member ): SuccessResponse { val body = memberProfileService.getProfileSummary(member) return SuccessResponse.ok(body) } + + @PatchMapping("/profile/image") + @Operation( + summary = "내 프로필 이미지 수정", + description = "이미지 URL을 입력받아 MemberProfile.profileImage를 수정하고 최종 URL을 반환" + ) + fun updateMyProfileImage( + @CurrentMember member: Member, + @RequestBody @Valid req: UpdateProfileImageRequest + ): SuccessResponse { + val body = memberProfileService.updateProfileImage(member, req) + return SuccessResponse.ok(body) + } + + @GetMapping("/profile/image/url") + @Operation( + summary = "프로필 이미지 업로드용 Presigned URL 발급", + description = "업로드 URL 반환" + ) + fun profileImagePostUrl( + @CurrentMember member: Member, + @RequestParam fileName: String + ): ResponseEntity> { + val dto = s3Service.getPostS3Url( + memberId = member.id!!, + filename = fileName, + folderName = FolderName.MEMBER_PROFILE.name, + option = UploadOption.IMAGE + ) + return ResponseEntity.ok(SuccessResponse.ok(GetPreSignedUrlDto(preSignedUrl = dto.preSignedUrl))) + } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index 294a6da..bd6656e 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -6,6 +6,8 @@ import onku.backend.domain.member.MemberErrorCode import onku.backend.domain.member.dto.MemberProfileResponse import onku.backend.domain.member.dto.OnboardingRequest import onku.backend.domain.member.dto.OnboardingResponse +import onku.backend.domain.member.dto.UpdateProfileImageRequest +import onku.backend.domain.member.dto.UpdateProfileImageResponse import onku.backend.domain.member.enums.ApprovalStatus import onku.backend.domain.member.repository.MemberProfileRepository import onku.backend.domain.member.repository.MemberRepository @@ -24,13 +26,17 @@ class MemberProfileService( if (member.hasInfo) { // 이미 온보딩 완료된 사용자 차단 throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) } - if (member.approval != ApprovalStatus.PENDING) { // PENDING 상태가 아닌 사용자 차단 throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) } - createOrUpdateProfile(member.id!!, req) - memberService.markOnboarded(member) + // 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 @@ -49,7 +55,8 @@ class MemberProfileService( school = req.school, major = req.major, part = req.part, - phoneNumber = req.phoneNumber + phoneNumber = req.phoneNumber, + profileImage = req.profileImage ) memberProfileRepository.save(profile) } else { @@ -58,10 +65,12 @@ class MemberProfileService( school = req.school, major = req.major, part = req.part, - phoneNumber = req.phoneNumber + phoneNumber = req.phoneNumber, + profileImage = req.profileImage ) } } + @Transactional(readOnly = true) fun getProfileSummary(member: Member): MemberProfileResponse { val profile = memberProfileRepository.findById(member.id!!) @@ -71,4 +80,11 @@ class MemberProfileService( part = profile.part ) } -} + + fun updateProfileImage(member: Member, req: UpdateProfileImageRequest): UpdateProfileImageResponse { + val profile = memberProfileRepository.findById(member.id!!) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + profile.profileImage = req.imageUrl + return UpdateProfileImageResponse(profileImageUrl = profile.profileImage!!) + } +} \ No newline at end of file From d1dda70266470c0c456e9b570f8d6d74d53b3b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:03:04 +0900 Subject: [PATCH 225/470] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20PresignedURL=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=8F=84=20?= =?UTF-8?q?=EC=98=A8=EB=B3=B4=EB=94=A9=20=ED=86=A0=ED=81=B0=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9A=94=EC=B2=AD=20=EA=B0=80=EB=8A=A5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#48?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/auth/config/SecurityConfig.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index 74a7810..c6a3f55 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -30,7 +30,10 @@ class SecurityConfig( "/api/v1/auth/reissue", ) // TODO: 엔드포인트가 늘어나면 arrayOf()로 수정 - private const val ONBOARDING_ENDPOINT = "/api/v1/members/onboarding/**" // 온보딩 + private val ONBOARDING_ENDPOINT = arrayOf( + "/api/v1/members/onboarding/**", + "/api/v1/members/profile/image/url" + ) private const val ADMIN_ENDPOINT = "/api/v1/auth/admin/**" // 운영진 전체 private val MANAGEMENT_ENDPOINT = arrayOf( "/api/v1/attendance/scan/**", @@ -52,7 +55,7 @@ class SecurityConfig( .requestMatchers(*ALLOWED_POST).permitAll() // 권한 별 엔드포인트 - .requestMatchers(ONBOARDING_ENDPOINT).hasRole(Role.GUEST.name) + .requestMatchers(*ONBOARDING_ENDPOINT).hasRole(Role.GUEST.name) .requestMatchers(ADMIN_ENDPOINT).hasRole(Role.ADMIN.name) .requestMatchers(*MANAGEMENT_ENDPOINT).hasRole(Role.MANAGEMENT.name) .anyRequest().hasRole(Role.USER.name) From d099b484c363ae639e809f733e5d1f7127c58fc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:20:31 +0900 Subject: [PATCH 226/470] =?UTF-8?q?feat:=20=EB=82=B4=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=9A=94=EC=95=BD=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=83=81=EB=B2=8C=EC=A0=90=20=EC=B4=9D?= =?UTF-8?q?=EC=A0=90=EB=8F=84=20=ED=95=A8=EA=BB=98=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?#49?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/controller/MemberController.kt | 2 +- .../domain/member/dto/MemberProfileResponse.kt | 3 ++- .../domain/member/service/MemberProfileService.kt | 11 +++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt index 6116902..b1353bb 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt @@ -41,7 +41,7 @@ class MemberController( @GetMapping("/profile/summary") @Operation( summary = "내 프로필 요약 조회", - description = "[TEMP] 현재 로그인한 회원의 이름(name)과 파트(part)만 반환" + description = "현재 로그인한 회원의 이름(name), 파트(part), 상벌점 총합(totalPoints) 반환" ) fun getMyProfileSummary( @CurrentMember member: Member diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt index 05bd452..23fc51f 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt @@ -4,5 +4,6 @@ import onku.backend.domain.member.enums.Part data class MemberProfileResponse( val name: String?, - val part: Part + val part: Part, + val totalPoints: Long ) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index bd6656e..ca7d076 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -11,6 +11,7 @@ import onku.backend.domain.member.dto.UpdateProfileImageResponse 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 org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -20,7 +21,8 @@ import org.springframework.transaction.annotation.Transactional class MemberProfileService( private val memberProfileRepository: MemberProfileRepository, private val memberRepository: MemberRepository, - private val memberService: MemberService + private val memberService: MemberService, + private val memberPointHistoryRepository: MemberPointHistoryRepository ) { fun submitOnboarding(member: Member, req: OnboardingRequest): OnboardingResponse { if (member.hasInfo) { // 이미 온보딩 완료된 사용자 차단 @@ -75,9 +77,14 @@ class MemberProfileService( 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() + return MemberProfileResponse( name = profile.name, - part = profile.part + part = profile.part, + totalPoints = total ) } From 413a1e1fd311e348dc3132ebe0834a713f97e398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:34:10 +0900 Subject: [PATCH 227/470] =?UTF-8?q?feat:=20=EC=9D=91=EB=8B=B5=EA=B0=92=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20dto=20=EC=88=98=EC=A0=95=20#49?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/dto/AttendanceTokenResponse.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenResponse.kt b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenResponse.kt index 5e405ef..a1a5506 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenResponse.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenResponse.kt @@ -1,8 +1,13 @@ package onku.backend.domain.attendance.dto +import onku.backend.domain.member.enums.Part import java.time.LocalDateTime -data class AttendanceTokenResponse ( +data class AttendanceTokenResponse( val token: String, - val expAt: LocalDateTime + val expAt: LocalDateTime, + val name: String, + val part: Part, + val school: String?, + val profileImageUrl: String? ) \ No newline at end of file From fc2a00a34d4e56585386f372303781b35ed3eb50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:34:46 +0900 Subject: [PATCH 228/470] =?UTF-8?q?feat:=20=EA=B0=81=20service=EC=9D=98=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=EA=B0=92=20=ED=98=95=ED=83=9C=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=EB=A5=BC=20=EC=9C=84=ED=95=9C=20dto=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#49?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/dto/AttendanceTokenCore.kt | 8 ++++++++ .../domain/member/dto/MemberProfileBasicsResponse.kt | 10 ++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceTokenCore.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/MemberProfileBasicsResponse.kt 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/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? +) From 36feae1a6687589507fc1000a63bf61d156fd194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:35:11 +0900 Subject: [PATCH 229/470] =?UTF-8?q?feat:=20dto=EB=AA=85=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=20#49?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/attendance/service/AttendanceService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 9951afb..3bc2da8 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -36,7 +36,7 @@ class AttendanceService( private val clock: Clock ) { @Transactional(readOnly = true) - fun issueAttendanceTokenFor(member: Member): AttendanceTokenResponse { + fun issueAttendanceTokenFor(member: Member): AttendanceTokenCore { val now = LocalDateTime.now(clock) val expAt = now.plusSeconds(AttendancePolicy.TOKEN_TTL_SECONDS) val token = tokenGenerator.generateOpaqueToken() @@ -48,7 +48,7 @@ class AttendanceService( expAt, AttendancePolicy.TOKEN_TTL_SECONDS ) - return AttendanceTokenResponse(token = token, expAt = expAt) + return AttendanceTokenCore(token = token, expAt = expAt) } private fun findOpenSession(now: LocalDateTime): Session? { From 9c4ea5777afa562e35f61bc65da954b0fbd8e32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:35:40 +0900 Subject: [PATCH 230/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20n?= =?UTF-8?q?ame,=20part,=20school,=20profile=20image=20url=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20#49?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/service/MemberProfileService.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index ca7d076..44f01ad 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -3,11 +3,7 @@ package onku.backend.domain.member.service import onku.backend.domain.member.Member import onku.backend.domain.member.MemberProfile import onku.backend.domain.member.MemberErrorCode -import onku.backend.domain.member.dto.MemberProfileResponse -import onku.backend.domain.member.dto.OnboardingRequest -import onku.backend.domain.member.dto.OnboardingResponse -import onku.backend.domain.member.dto.UpdateProfileImageRequest -import onku.backend.domain.member.dto.UpdateProfileImageResponse +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 @@ -88,6 +84,19 @@ class MemberProfileService( ) } + @Transactional(readOnly = true) + fun getProfileBasics(member: Member): MemberProfileBasicsResponse { + val profile = memberProfileRepository.findById(member.id!!) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + return MemberProfileBasicsResponse( + name = profile.name ?: "Unknown", + part = profile.part, + school = profile.school, + profileImageUrl = profile.profileImage + ) + } + fun updateProfileImage(member: Member, req: UpdateProfileImageRequest): UpdateProfileImageResponse { val profile = memberProfileRepository.findById(member.id!!) .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } From 6f2ffb198f9a455c173d1a419a58d77f2b6ea0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:36:38 +0900 Subject: [PATCH 231/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=EC=9A=A9=20t?= =?UTF-8?q?oken=20=EB=B0=9C=EA=B8=89=20AttendanceService=EC=99=80=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20MemberProfileService=EC=9D=98=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=EA=B0=92=20=EC=A1=B0=ED=95=A9=20#49?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/facade/AttendanceFacade.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/facade/AttendanceFacade.kt 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 + ) + } +} From 1315b20d0ec2136a40782e6e84ab2447f6c809a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:37:20 +0900 Subject: [PATCH 232/470] =?UTF-8?q?feat:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=EC=97=90=EC=84=9C=20=EC=83=88=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=20facade=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#49?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/controller/AttendanceController.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt b/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt index 179554f..05e3875 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag import onku.backend.domain.attendance.dto.AttendanceRequest import onku.backend.domain.attendance.dto.AttendanceResponse import onku.backend.domain.attendance.dto.AttendanceTokenResponse +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 @@ -17,14 +18,15 @@ import org.springframework.web.bind.annotation.* @RequestMapping("/api/v1/attendance") @Tag(name = "출석 API") class AttendanceController( - private val attendanceService: AttendanceService + private val attendanceService: AttendanceService, + private val attendanceFacade: AttendanceFacade ) { - @PostMapping("/token") - @Operation(summary = "출석용 토큰 발급 [USER]", description = "15초 유효") + @Operation(summary = "출석용 토큰 발급 [USER]", description = "15초 유효 + 프로필(이름/파트/학교/프사) 포함") fun issueQrToken(@CurrentMember member: Member): ResponseEntity> { val headers = HttpHeaders().apply { add(HttpHeaders.CACHE_CONTROL, "no-store") } - return ResponseEntity.ok().headers(headers).body(SuccessResponse.ok(attendanceService.issueAttendanceTokenFor(member))) + val body = attendanceFacade.issueTokenWithProfile(member) + return ResponseEntity.ok().headers(headers).body(SuccessResponse.ok(body)) } @PostMapping("/scan") From 478c5f5b6d857506b4243123d20bb4ca3c05907f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 24 Oct 2025 20:48:01 +0900 Subject: [PATCH 233/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=83=81=EB=B2=8C=EC=A0=90=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=EC=97=90=EC=84=9C=20response=20=EA=B0=92?= =?UTF-8?q?=EC=97=90=20name=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20#4?= =?UTF-8?q?9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/point/dto/MemberPointHistoryResponse.kt | 3 ++- .../domain/point/service/MemberPointHistoryService.kt | 11 ++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/dto/MemberPointHistoryResponse.kt b/src/main/kotlin/onku/backend/domain/point/dto/MemberPointHistoryResponse.kt index b8eb45c..8c17408 100644 --- a/src/main/kotlin/onku/backend/domain/point/dto/MemberPointHistoryResponse.kt +++ b/src/main/kotlin/onku/backend/domain/point/dto/MemberPointHistoryResponse.kt @@ -15,10 +15,11 @@ data class MemberPointHistory( @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/service/MemberPointHistoryService.kt b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt index b35013e..2ff5404 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt @@ -1,9 +1,12 @@ package onku.backend.domain.point.service import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.repository.MemberProfileRepository 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 @@ -11,7 +14,8 @@ import kotlin.math.max @Service class MemberPointHistoryService( - private val recordRepository: MemberPointHistoryRepository + private val recordRepository: MemberPointHistoryRepository, + private val memberProfileRepository: MemberProfileRepository ) { @Transactional(readOnly = true) @@ -19,6 +23,10 @@ class MemberPointHistoryService( val safePage = max(0, page1Based - 1) 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() @@ -31,6 +39,7 @@ class MemberPointHistoryService( return MemberPointHistoryResponse( memberId = member.id!!, + name = name, plusPoints = plusPoints, minusPoints = minusPoints, totalPoints = totalPoints, From f2a1c703272e3e50d526c044dede4ee56798e153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 24 Oct 2025 21:33:08 +0900 Subject: [PATCH 234/470] =?UTF-8?q?fix=20:=20h2=EB=A9=94=EB=AA=A8=EB=A6=AC?= =?UTF-8?q?=20DB=EC=99=80=EC=9D=98=20=ED=98=B8=ED=99=98=EC=84=B1=EC=9D=84?= =?UTF-8?q?=20=ED=99=95=EB=B3=B4=ED=95=9C=EB=8B=A4.=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/finalize/FinalizeBootTrigger.kt | 4 ++-- .../session/repository/SessionRepository.kt | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeBootTrigger.kt b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeBootTrigger.kt index f87794e..4569a2d 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeBootTrigger.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/finalize/FinalizeBootTrigger.kt @@ -22,12 +22,12 @@ class FinalizeBootTrigger( val pivot = now.minusMinutes(AttendancePolicy.ABSENT_START_MINUTES) // 이미 결석 판정 시각을 지난 세션 → 즉시 finalize - sessionRepository.findFinalizeDue(pivot).forEach { s -> + sessionRepository.findFinalizeDue(pivot.toLocalDate(), pivot.toLocalTime()).forEach { s -> runCatching { attendanceFinalizeService.finalizeSession(s.id!!) } } // 아직 결석 판정 시각이 지나지 않은 세션 → 경계 시각으로 재예약 - sessionRepository.findUnfinalizedAfter(pivot).forEach { s -> + 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/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index 1df1b93..e19ef84 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -9,6 +9,7 @@ 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 { @@ -60,16 +61,22 @@ interface SessionRepository : CrudRepository { FROM Session s JOIN s.sessionDetail sd WHERE s.attendanceFinalized = false - AND function('timestamp', s.startDate, sd.startTime) <= :pivot + AND (s.startDate < :pivotDate or (s.startDate = :pivotDate and sd.startTime <= :pivotTime)) """) - fun findFinalizeDue(@Param("pivot") pivot: LocalDateTime): List + 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 function('timestamp', s.startDate, sd.startTime) > :pivot + AND (s.startDate > :pivotDate or (s.startDate = :pivotDate and sd.startTime > :pivotTime)) """) - fun findUnfinalizedAfter(@Param("pivot") pivot: LocalDateTime): List + fun findUnfinalizedAfter( + @Param("pivotDate") pivotDate: LocalDate, + @Param("pivotTime") pivotTime: LocalTime + ): List } From 56909bfd9b83242fc2034c3faaf9210da7eb2066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 24 Oct 2025 22:49:05 +0900 Subject: [PATCH 235/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=EC=8B=9C=20=EC=8B=9C=EC=9E=91?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=EA=B3=BC=20=EC=A2=85=EB=A3=8C=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=EC=98=88=EC=8B=9C=20=EC=9A=94=EC=B2=AD=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4.=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/dto/request/UpsertSessionDetailRequest.kt | 3 +++ 1 file changed, 3 insertions(+) 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 index 79fd14d..61ccc25 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/request/UpsertSessionDetailRequest.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/request/UpsertSessionDetailRequest.kt @@ -1,6 +1,7 @@ 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 @@ -12,9 +13,11 @@ data class UpsertSessionDetailRequest( @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, ) From 0303b3b988e5dd8efbb1905778fa39fc6b3079fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 24 Oct 2025 22:56:14 +0900 Subject: [PATCH 236/470] =?UTF-8?q?refactor=20:=20=EB=B6=88=EC=B0=B8?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EC=84=9C=EC=9A=A9=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=EC=9D=BC=EC=9E=90=EB=A5=BC=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20dto=EC=97=90=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= =?UTF-8?q?.=20#51?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/dto/response/SessionAboutAbsenceResponse.kt | 3 +++ .../onku/backend/domain/session/service/SessionService.kt | 1 + 2 files changed, 4 insertions(+) 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 index 228dae2..ec6a1a0 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/response/SessionAboutAbsenceResponse.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/SessionAboutAbsenceResponse.kt @@ -1,6 +1,7 @@ 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") @@ -9,6 +10,8 @@ data class SessionAboutAbsenceResponse( 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/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index 0973f76..13030e6 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -35,6 +35,7 @@ class SessionService( sessionId = s.id, title = s.title, week = s.week, + startDate = s.startDate, active = active ) } From 304ef0ca4b6d0dba29bab50526588b35b7010a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 24 Oct 2025 23:31:36 +0900 Subject: [PATCH 237/470] =?UTF-8?q?refactor=20:=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EC=9A=A9=20=ED=81=90=ED=94=BD=20api?= =?UTF-8?q?=EC=99=80=20=EB=B6=84=EB=A6=AC=20#28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/kupick/controller/manager/KupickManagerController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 612e0a9..b0b9691 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt @@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/kupick/manage") -@Tag(name = "큐픽 API", description = "큐픽 관련 CRUD API") +@Tag(name = "[관리자용] 큐픽 API", description = "큐픽 관련 CRUD API") class KupickManagerController( private val kupickFacade: KupickFacade ) { From 9ddddbd4c5564fc4ba6b85a0a18b4c5261a64e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 26 Oct 2025 16:15:16 +0900 Subject: [PATCH 238/470] =?UTF-8?q?feat:=20point=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PointManagerController.kt} | 8 ++++---- .../point/controller/{ => user}/MemberPointController.kt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/main/kotlin/onku/backend/domain/point/controller/{AdminPointController.kt => manager/PointManagerController.kt} (96%) rename src/main/kotlin/onku/backend/domain/point/controller/{ => user}/MemberPointController.kt (96%) diff --git a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt b/src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt similarity index 96% rename from src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt rename to src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt index 0c8fbfb..0fa538d 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/AdminPointController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.point.controller +package onku.backend.domain.point.controller.manager import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag @@ -12,12 +12,12 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @RestController -@RequestMapping("/api/v1/admin/points") +@RequestMapping("/api/v1/points/manage") @Tag( - name = "운영진 상벌점", + name = "[MANAGEMENT] 운영진 상벌점", description = "운영진 상벌점 대시보드 조회 API" ) -class AdminPointController( +class PointManagerController( private val adminPointsService: AdminPointService, private val commandService: AdminPointCommandService ) { diff --git a/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt b/src/main/kotlin/onku/backend/domain/point/controller/user/MemberPointController.kt similarity index 96% rename from src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt rename to src/main/kotlin/onku/backend/domain/point/controller/user/MemberPointController.kt index 772bd51..0d42fdb 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/MemberPointController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/user/MemberPointController.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.point.controller +package onku.backend.domain.point.controller.user import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag From 492b40f4fd2cca1590033c71a517fea68c7ef902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 26 Oct 2025 16:17:32 +0900 Subject: [PATCH 239/470] =?UTF-8?q?feat:=20admin=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B6=84=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminController.kt | 32 ------------------- .../member/controller/MemberController.kt | 19 ++++++++++- .../member/dto/MemberApprovalResponse.kt | 10 ++++++ .../member/dto/UpdateApprovalRequest.kt | 9 ++++++ .../domain/member/service/MemberService.kt | 28 ++++++++++++++++ 5 files changed, 65 insertions(+), 33 deletions(-) delete mode 100644 src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/MemberApprovalResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/UpdateApprovalRequest.kt diff --git a/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt b/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt deleted file mode 100644 index 848e28b..0000000 --- a/src/main/kotlin/onku/backend/domain/admin/controller/AdminController.kt +++ /dev/null @@ -1,32 +0,0 @@ -package onku.backend.domain.admin.controller - -import onku.backend.domain.admin.dto.UpdateApprovalRequest -import onku.backend.domain.admin.dto.MemberApprovalResponse -import onku.backend.domain.admin.service.AdminService -import onku.backend.global.response.SuccessResponse -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.* - -@Tag(name = "관리자 API", description = "관리자 권한용 API") -@RestController -@RequestMapping("/api/v1/admin/members") -class AdminController( - private val adminService: AdminService -) { - - @Operation( - summary = "[관리자] 회원 승인 상태 변경", - description = "PENDING 상태의 회원만 승인/거절할 수 있습니다. (PENDING → APPROVED/REJECTED)" - ) - @PatchMapping("/{memberId}/approval") - fun updateApproval( - @PathVariable memberId: Long, - @RequestBody @Valid body: UpdateApprovalRequest - ): ResponseEntity> { - val result = adminService.updateApproval(memberId, body.status) - return ResponseEntity.ok(SuccessResponse.ok(result)) - } -} diff --git a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt index b1353bb..e78d854 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt @@ -3,9 +3,12 @@ package onku.backend.domain.member.controller 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.MemberApprovalResponse +import onku.backend.domain.member.dto.UpdateApprovalRequest import onku.backend.domain.member.Member import onku.backend.domain.member.dto.* 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.GetPreSignedUrlDto @@ -21,7 +24,8 @@ import org.springframework.web.bind.annotation.* @Tag(name = "회원 API", description = "온보딩 및 프로필 관련 API") class MemberController( private val memberProfileService: MemberProfileService, - private val s3Service: S3Service + private val s3Service: S3Service, + private val memberService: MemberService ) { @PostMapping("/onboarding") @@ -80,4 +84,17 @@ class MemberController( ) return ResponseEntity.ok(SuccessResponse.ok(GetPreSignedUrlDto(preSignedUrl = dto.preSignedUrl))) } + + @Operation( + summary = "[STAFF] 회원 승인 상태 변경", + description = "PENDING 상태의 회원만 승인/거절할 수 있습니다. (PENDING → APPROVED/REJECTED)" + ) + @PatchMapping("/{memberId}/approval") + fun updateApproval( + @PathVariable memberId: Long, + @RequestBody @Valid body: UpdateApprovalRequest + ): ResponseEntity> { + val result = memberService.updateApproval(memberId, body.status) + return ResponseEntity.ok(SuccessResponse.ok(result)) + } } \ No newline at end of file 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/UpdateApprovalRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/UpdateApprovalRequest.kt new file mode 100644 index 0000000..7823591 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/UpdateApprovalRequest.kt @@ -0,0 +1,9 @@ +package onku.backend.domain.member.dto + +import jakarta.validation.constraints.NotNull +import onku.backend.domain.member.enums.ApprovalStatus + +data class UpdateApprovalRequest( + @field:NotNull + val status: ApprovalStatus +) \ 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 index c5764d2..1519101 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -2,6 +2,7 @@ package onku.backend.domain.member.service import onku.backend.domain.member.Member import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.dto.MemberApprovalResponse import onku.backend.domain.member.enums.ApprovalStatus import onku.backend.domain.member.enums.Role import onku.backend.domain.member.enums.SocialType @@ -70,4 +71,31 @@ class MemberService( } memberRepository.deleteById(memberId) } + + fun updateApproval(memberId: Long, targetStatus: ApprovalStatus): MemberApprovalResponse { + if (targetStatus == ApprovalStatus.PENDING) { + throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) + } + + val member: Member = memberRepository.findById(memberId) + .orElseThrow { 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 -> { } + } + + val saved = memberRepository.save(member) + + return MemberApprovalResponse( + memberId = saved.id!!, + role = saved.role, + approval = saved.approval + ) + } } From ca6d44946e14fb518cb9f381725071b63847da9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 26 Oct 2025 16:18:29 +0900 Subject: [PATCH 240/470] =?UTF-8?q?feat:=20session=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SessionStaffController.kt} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename src/main/kotlin/onku/backend/domain/session/controller/{manager/SessionManagerController.kt => staff/SessionStaffController.kt} (95%) diff --git a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt b/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt similarity index 95% rename from src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt rename to src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt index 17b651d..1efb924 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/manager/SessionManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.session.controller.manager +package onku.backend.domain.session.controller.staff import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag @@ -21,9 +21,9 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @RestController -@RequestMapping("/api/v1/session/manager") -@Tag(name = "[관리자용] 세션 API", description = "세션 관련 API") -class SessionManagerController( +@RequestMapping("/api/v1/session/staff") +@Tag(name = "[STAFF] 세션 API", description = "세션 관련 API") +class SessionStaffController( private val sessionFacade: SessionFacade ) { @PostMapping("") From 4b28d0028421084c20c1f996be508204f7761991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 26 Oct 2025 16:19:42 +0900 Subject: [PATCH 241/470] =?UTF-8?q?feat:=20admin=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20member=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dto/MemberApprovalResponse.kt | 10 ----- .../domain/admin/dto/UpdateApprovalRequest.kt | 9 ---- .../domain/admin/service/AdminService.kt | 8 ---- .../domain/admin/service/AdminServiceImpl.kt | 44 ------------------- 4 files changed, 71 deletions(-) delete mode 100644 src/main/kotlin/onku/backend/domain/admin/dto/MemberApprovalResponse.kt delete mode 100644 src/main/kotlin/onku/backend/domain/admin/dto/UpdateApprovalRequest.kt delete mode 100644 src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt delete mode 100644 src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt diff --git a/src/main/kotlin/onku/backend/domain/admin/dto/MemberApprovalResponse.kt b/src/main/kotlin/onku/backend/domain/admin/dto/MemberApprovalResponse.kt deleted file mode 100644 index 71a8313..0000000 --- a/src/main/kotlin/onku/backend/domain/admin/dto/MemberApprovalResponse.kt +++ /dev/null @@ -1,10 +0,0 @@ -package onku.backend.domain.admin.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/admin/dto/UpdateApprovalRequest.kt b/src/main/kotlin/onku/backend/domain/admin/dto/UpdateApprovalRequest.kt deleted file mode 100644 index 767bbaf..0000000 --- a/src/main/kotlin/onku/backend/domain/admin/dto/UpdateApprovalRequest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package onku.backend.domain.admin.dto - -import jakarta.validation.constraints.NotNull -import onku.backend.domain.member.enums.ApprovalStatus - -data class UpdateApprovalRequest( - @field:NotNull - val status: ApprovalStatus -) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt b/src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt deleted file mode 100644 index 35c24a4..0000000 --- a/src/main/kotlin/onku/backend/domain/admin/service/AdminService.kt +++ /dev/null @@ -1,8 +0,0 @@ -package onku.backend.domain.admin.service - -import onku.backend.domain.admin.dto.MemberApprovalResponse -import onku.backend.domain.member.enums.ApprovalStatus - -interface AdminService { - fun updateApproval(memberId: Long, targetStatus: ApprovalStatus): MemberApprovalResponse -} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt b/src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt deleted file mode 100644 index 45da661..0000000 --- a/src/main/kotlin/onku/backend/domain/admin/service/AdminServiceImpl.kt +++ /dev/null @@ -1,44 +0,0 @@ -package onku.backend.domain.admin.service - -import onku.backend.domain.admin.dto.MemberApprovalResponse -import onku.backend.domain.member.Member -import onku.backend.domain.member.enums.ApprovalStatus -import onku.backend.domain.member.repository.MemberRepository -import onku.backend.domain.member.MemberErrorCode -import onku.backend.global.exception.CustomException -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -@Transactional -class AdminServiceImpl( - private val memberRepository: MemberRepository -) : AdminService { - - override fun updateApproval(memberId: Long, targetStatus: ApprovalStatus): MemberApprovalResponse { - if (targetStatus == ApprovalStatus.PENDING) { - throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) - } - - val member: Member = memberRepository.findById(memberId) - .orElseThrow { 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 -> { } - } - - val saved = memberRepository.save(member) - - return MemberApprovalResponse( - memberId = saved.id!!, - role = saved.role, - approval = saved.approval - ) - } -} From c6c8567fc7894906bfebf5abd5ff1c183c14e2aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 26 Oct 2025 16:20:08 +0900 Subject: [PATCH 242/470] =?UTF-8?q?chore:=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/controller/AttendanceController.kt | 2 +- .../domain/kupick/controller/manager/KupickManagerController.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt b/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt index 05e3875..512f09c 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt @@ -30,7 +30,7 @@ class AttendanceController( } @PostMapping("/scan") - @Operation(summary = "출석 스캔 [ADMIN]", description = "열린 세션 자동 선택 → 토큰 검증 & 소비 → insert") + @Operation(summary = "출석 스캔 [MANAGEMENT]", description = "열린 세션 자동 선택 → 토큰 검증 & 소비 → insert") fun scan(@CurrentMember admin: Member, @RequestBody req: AttendanceRequest): SuccessResponse { return SuccessResponse.ok(attendanceService.scanAndRecordBy(admin, req.token)) } 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 index 612e0a9..801ccc1 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt @@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/kupick/manage") -@Tag(name = "큐픽 API", description = "큐픽 관련 CRUD API") +@Tag(name = "[MANAGEMENT] 큐픽 API", description = "큐픽 관련 CRUD API") class KupickManagerController( private val kupickFacade: KupickFacade ) { From 163893ebe381f1e9ae251501c04ef4f3dbef813c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 26 Oct 2025 16:20:23 +0900 Subject: [PATCH 243/470] =?UTF-8?q?feat:=20=EA=B6=8C=ED=95=9C=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=EC=88=98=EC=A0=95=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/enums/Role.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/enums/Role.kt b/src/main/kotlin/onku/backend/domain/member/enums/Role.kt index f64acb7..108ca9d 100644 --- a/src/main/kotlin/onku/backend/domain/member/enums/Role.kt +++ b/src/main/kotlin/onku/backend/domain/member/enums/Role.kt @@ -3,19 +3,21 @@ package onku.backend.domain.member.enums import io.swagger.v3.oas.annotations.media.Schema @Schema( - description = "사용자 권한 종류 (계층: MANAGEMENT ⟶ ADMIN ⟶ USER ⟶ GUEST)", + description = "사용자 권한 계층: EXECUTIVE(회장단) > MANAGEMENT(경총) > STAFF(운영진) > USER(학회원) > GUEST(온보딩)", example = "USER" ) enum class Role { - @Schema(description = "게스트(온보딩 전용)") GUEST, - @Schema(description = "일반 사용자") USER, - @Schema(description = "운영진(사용자 권한 포함)") ADMIN, - @Schema(description = "경영/관리(운영진+사용자 권한 포함)") MANAGEMENT; + @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") - ADMIN -> listOf("ADMIN", "USER") - MANAGEMENT -> listOf("MANAGEMENT", "ADMIN", "USER") + STAFF -> listOf("STAFF", "USER") + MANAGEMENT -> listOf("MANAGEMENT", "STAFF", "USER") + EXECUTIVE -> listOf("EXECUTIVE", "MANAGEMENT", "STAFF", "USER") } } From 65a9f6af4c75ce461f3a02c4629f8b401f984806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sun, 26 Oct 2025 16:20:38 +0900 Subject: [PATCH 244/470] =?UTF-8?q?feat:=20=EC=8B=9C=ED=81=90=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=EB=8B=A8=EC=97=90=EC=84=9C=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=A0=81=EC=9A=A9=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/config/SecurityConfig.kt | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index c6a3f55..46e85aa 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -29,15 +29,24 @@ class SecurityConfig( "/api/v1/auth/kakao", "/api/v1/auth/reissue", ) - // TODO: 엔드포인트가 늘어나면 arrayOf()로 수정 + private val ONBOARDING_ENDPOINT = arrayOf( "/api/v1/members/onboarding/**", "/api/v1/members/profile/image/url" ) - private const val ADMIN_ENDPOINT = "/api/v1/auth/admin/**" // 운영진 전체 - private val MANAGEMENT_ENDPOINT = arrayOf( - "/api/v1/attendance/scan/**", - "/api/v1/kupick/manage/**") // 경총 + + private val STAFF_ENDPOINT = arrayOf( // 운영진 + "/api/v1/session/staff/**", + "/api/v1/members/*/approval" + ) + + private val MANAGEMENT_ENDPOINT = arrayOf( // 경총 + "/api/v1/kupick/manage/**", + "/api/v1/points/manage/**", + "/api/v1/attendance/scan" + ) + +// private val EXECUTIVE = arrayOf("") // 회장단 } @Bean @@ -56,8 +65,9 @@ class SecurityConfig( // 권한 별 엔드포인트 .requestMatchers(*ONBOARDING_ENDPOINT).hasRole(Role.GUEST.name) - .requestMatchers(ADMIN_ENDPOINT).hasRole(Role.ADMIN.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) From 3f7583677dd2a1f4d5a1c462919df4dbc537446e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 27 Oct 2025 02:06:01 +0900 Subject: [PATCH 245/470] =?UTF-8?q?refactor=20:=20=EC=9D=91=EB=8B=B5=20dto?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EB=B3=80=ED=99=98(?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=20=EC=9A=94=EC=B2=AD)=20#28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/manager/KupickManagerController.kt | 8 ++------ .../onku/backend/domain/kupick/facade/KupickFacade.kt | 11 ++++------- .../domain/kupick/repository/KupickRepository.kt | 5 +---- .../backend/domain/kupick/service/KupickService.kt | 6 +----- 4 files changed, 8 insertions(+), 22 deletions(-) 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 index 801ccc1..48f225f 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/manager/KupickManagerController.kt @@ -5,7 +5,6 @@ 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.page.PageResponse import onku.backend.global.response.SuccessResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -24,11 +23,8 @@ class KupickManagerController( fun submitApplication( year : Int, month : Int, - @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(kupickFacade.showUpdate(year, month, safePage, size))) + ) : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.showUpdate(year, month))) } @PostMapping("/approval") diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index 9cd3e66..dccedd7 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -5,12 +5,10 @@ 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.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 @Component @@ -48,10 +46,9 @@ class KupickFacade( ) } - fun showUpdate(year : Int, month : Int, page: Int, size: Int): PageResponse { - val pageRequest = PageRequest.of(page, size) - val profiles = kupickService.findAllAsShowUpdateResponse(year, month, pageRequest) - val dtoPage = profiles.map { p -> + 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 @@ -74,7 +71,7 @@ class KupickFacade( approval = p.kupick.approval ) } - return PageResponse.from(dtoPage) + return dtoList } fun decideApproval(kupickApprovalRequest: KupickApprovalRequest): Boolean { diff --git a/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt index a328a59..456f55f 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt @@ -4,8 +4,6 @@ import onku.backend.domain.kupick.Kupick 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.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 @@ -56,8 +54,7 @@ interface KupickRepository : JpaRepository { fun findAllWithProfile( @Param("start") start: LocalDateTime, @Param("end") end: LocalDateTime, - pageable: Pageable - ): Page + ): List @Query( """ diff --git a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt index 6ff67b6..549b1af 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -7,10 +7,7 @@ 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.exception.ErrorCode import onku.backend.global.time.TimeRangeUtil -import org.springframework.data.domain.Page -import org.springframework.data.domain.PageRequest import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -56,12 +53,11 @@ class KupickService( } @Transactional(readOnly = true) - fun findAllAsShowUpdateResponse(year : Int, month : Int, pageRequest: PageRequest): Page { + fun findAllAsShowUpdateResponse(year : Int, month : Int): List { val monthObject = TimeRangeUtil.monthRange(year, month) return kupickRepository.findAllWithProfile( monthObject.startOfMonth, monthObject.startOfNextMonth, - pageRequest ) } From 995038e780735ba2489da989b973583b420e7a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 27 Oct 2025 02:40:20 +0900 Subject: [PATCH 246/470] =?UTF-8?q?refactor=20:=20=ED=81=90=ED=94=BD=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=84=9C=EB=A5=98=20=EC=A0=9C=EC=B6=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/kupick/KupickErrorCode.kt | 1 + .../controller/user/KupickController.kt | 3 ++- .../domain/kupick/facade/KupickFacade.kt | 13 ++++++++---- .../domain/kupick/service/KupickService.kt | 20 +++++++++++++------ .../global/s3/dto/GetUpdateAndDeleteUrlDto.kt | 10 ++++++++++ 5 files changed, 36 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/onku/backend/global/s3/dto/GetUpdateAndDeleteUrlDto.kt diff --git a/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt b/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt index e938ac6..f0bb476 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/KupickErrorCode.kt @@ -11,4 +11,5 @@ enum class KupickErrorCode( 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/user/KupickController.kt b/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt index ea92b7d..abfd3de 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt @@ -8,6 +8,7 @@ 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 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 @@ -27,7 +28,7 @@ class KupickController( ) fun submitApplication( @CurrentMember member : Member, fileName : String - ) : ResponseEntity> { + ) : ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.submitApplication(member, fileName))) } diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index dccedd7..48fbf55 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -6,6 +6,7 @@ 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.s3.dto.GetPreSignedUrlDto +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 @@ -16,11 +17,15 @@ class KupickFacade( private val s3Service: S3Service, private val kupickService: KupickService, ) { - fun submitApplication(member: Member, fileName: String): GetPreSignedUrlDto { + fun submitApplication(member: Member, fileName: String): GetUpdateAndDeleteUrlDto { val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_APPLICATION.name, UploadOption.IMAGE) - kupickService.submitApplication(member, signedUrlDto.key) - return GetPreSignedUrlDto( - signedUrlDto.preSignedUrl + val oldDeletePreSignedUrl = kupickService + .submitApplication(member, signedUrlDto.key) + ?.let { oldKey -> s3Service.getDeleteS3Url(oldKey).preSignedUrl } + ?: "" + return GetUpdateAndDeleteUrlDto( + signedUrlDto.preSignedUrl, + oldDeletePreSignedUrl ) } diff --git a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt index 549b1af..5be0925 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -18,17 +18,25 @@ class KupickService( private val kupickRepository: KupickRepository, ) { @Transactional - fun submitApplication(member: Member, applicationUrl : String) { + fun submitApplication(member: Member, applicationUrl : String) : String? { val monthObject = TimeRangeUtil.getCurrentMonthRange() val existing = kupickRepository.findFirstByMemberAndApplicationDateBetween( member, monthObject.startOfMonth, monthObject.startOfNextMonth ) + val now = LocalDateTime.now() - existing - ?.updateApplication(applicationUrl, now) - ?: kupickRepository.save( - Kupick.createApplication(member, applicationUrl, 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 diff --git a/src/main/kotlin/onku/backend/global/s3/dto/GetUpdateAndDeleteUrlDto.kt b/src/main/kotlin/onku/backend/global/s3/dto/GetUpdateAndDeleteUrlDto.kt new file mode 100644 index 0000000..0c3f269 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/s3/dto/GetUpdateAndDeleteUrlDto.kt @@ -0,0 +1,10 @@ +package onku.backend.global.s3.dto + +import io.swagger.v3.oas.annotations.media.Schema + +data class GetUpdateAndDeleteUrlDto( + @Schema(description = "업로드 할 파일의 url(업로드용 presignedUrl)", example = "https://S3:~") + val newUrl : String?, + @Schema(description = "오래된 파일의 url(삭제용 presignedUrl)", example = "https://S3:~") + val oldUrl : String?, +) From 1d4e1d38e6e04c415784ce0fcaf67d5d48283962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 27 Oct 2025 02:53:49 +0900 Subject: [PATCH 247/470] =?UTF-8?q?refactor=20:=20=ED=81=90=ED=94=BD=20?= =?UTF-8?q?=EC=8B=9C=EC=B2=AD=20=EC=84=9C=EB=A5=98=20=EC=A0=9C=EC=B6=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kupick/controller/user/KupickController.kt | 3 +-- .../backend/domain/kupick/facade/KupickFacade.kt | 13 ++++++++----- .../backend/domain/kupick/service/KupickService.kt | 7 ++++++- 3 files changed, 15 insertions(+), 8 deletions(-) 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 index abfd3de..55a2754 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/controller/user/KupickController.kt @@ -7,7 +7,6 @@ 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.GetPreSignedUrlDto import onku.backend.global.s3.dto.GetUpdateAndDeleteUrlDto import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -39,7 +38,7 @@ class KupickController( ) fun submitView( @CurrentMember member: Member, fileName: String - ) : ResponseEntity> { + ) : ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(kupickFacade.submitView(member, fileName))) } diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index 48fbf55..def0c65 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -5,7 +5,6 @@ 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.s3.dto.GetPreSignedUrlDto import onku.backend.global.s3.dto.GetUpdateAndDeleteUrlDto import onku.backend.global.s3.enums.FolderName import onku.backend.global.s3.enums.UploadOption @@ -29,11 +28,15 @@ class KupickFacade( ) } - fun submitView(member: Member, fileName: String): GetPreSignedUrlDto { + fun submitView(member: Member, fileName: String): GetUpdateAndDeleteUrlDto { val signedUrlDto = s3Service.getPostS3Url(member.id!!, fileName, FolderName.KUPICK_VIEW.name, UploadOption.IMAGE) - kupickService.submitView(member, signedUrlDto.key) - return GetPreSignedUrlDto( - signedUrlDto.preSignedUrl + val oldDeletePreSignedUrl = kupickService + .submitView(member, signedUrlDto.key) + ?.let { oldKey -> s3Service.getDeleteS3Url(oldKey).preSignedUrl } + ?: "" + return GetUpdateAndDeleteUrlDto( + signedUrlDto.preSignedUrl, + oldDeletePreSignedUrl ) } diff --git a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt index 5be0925..f1e5817 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -40,14 +40,19 @@ class KupickService( } @Transactional - fun submitView(member: Member, viewUrl: String) { + 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) From 7d4e49ab6fa68097e7ecdba11a49d7c9e1f6f154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 27 Oct 2025 13:30:49 +0900 Subject: [PATCH 248/470] =?UTF-8?q?feat=20:=20=EA=B8=88=EC=A3=BC=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=A0=95=EB=B3=B4=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80=20#52?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/user/SessionController.kt | 10 +++++++++ .../dto/response/ThisWeekSessionInfo.kt | 22 +++++++++++++++++++ .../domain/session/facade/SessionFacade.kt | 4 ++++ .../session/repository/SessionRepository.kt | 21 ++++++++++++++++++ .../projection/ThisWeekSessionProjection.kt | 14 ++++++++++++ .../domain/session/service/SessionService.kt | 17 ++++++++++++++ 6 files changed, 88 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/response/ThisWeekSessionInfo.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/repository/projection/ThisWeekSessionProjection.kt 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 index 6ab3312..8d656b9 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt @@ -3,6 +3,7 @@ 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.SessionAboutAbsenceResponse +import onku.backend.domain.session.dto.response.ThisWeekSessionInfo import onku.backend.domain.session.facade.SessionFacade import onku.backend.global.response.SuccessResponse import org.springframework.http.ResponseEntity @@ -25,4 +26,13 @@ class SessionController( ) : ResponseEntity>> { return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showSessionAboutAbsence())) } + + @GetMapping("/this-week") + @Operation( + summary = "금주 세션 정보 조회", + description = "금주 세션 정보를 조회합니다." + ) + fun showThisWeekSessionInfo() : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showThisWeekSessionInfo())) + } } \ No newline at end of file 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..0a86e6b --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/ThisWeekSessionInfo.kt @@ -0,0 +1,22 @@ +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? +) diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index 19b98c5..83972e4 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -112,4 +112,8 @@ class SessionFacade( sessionImages = imageDtos ) } + + fun showThisWeekSessionInfo(): List { + return sessionService.getThisWeekSession() + } } \ 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 index e19ef84..16673b5 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -1,6 +1,7 @@ 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 @@ -79,4 +80,24 @@ interface SessionRepository : CrudRepository { @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 + 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 } 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/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index 13030e6..128a0a4 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -5,9 +5,11 @@ import onku.backend.domain.session.Session 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.ThisWeekSessionInfo import onku.backend.domain.session.repository.SessionRepository import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode +import onku.backend.global.time.TimeRangeUtil import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.repository.findByIdOrNull @@ -74,4 +76,19 @@ class SessionService( ) } } + + 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 + ) + } + } } \ No newline at end of file From db5f1140fa0670c887f0f01965d2dd370af4f735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 27 Oct 2025 13:31:22 +0900 Subject: [PATCH 249/470] =?UTF-8?q?refactor=20:=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=ED=95=A8=EC=88=98=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=EC=83=81=EC=88=98=20=EC=A0=84=EC=97=AD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20#52?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/time/TimeRangeUtil.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt b/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt index a30bb99..b7e88f6 100644 --- a/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt +++ b/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt @@ -1,26 +1,36 @@ package onku.backend.global.time -import java.time.LocalDateTime -import java.time.YearMonth -import java.time.ZoneId +import java.time.* +import java.time.temporal.TemporalAdjusters object TimeRangeUtil { + private val ZONE: ZoneId = ZoneId.of("Asia/Seoul") data class MonthRange( val startOfMonth: LocalDateTime, val startOfNextMonth: LocalDateTime ) + data class WeekRange( + val startOfWeek: LocalDate, + val endOfWeek: LocalDate + ) - fun getCurrentMonthRange(zoneId: ZoneId = ZoneId.of("Asia/Seoul")): MonthRange { + fun getCurrentMonthRange(zoneId: ZoneId = ZONE): MonthRange { val now = LocalDateTime.now(zoneId) val startOfMonth = now.toLocalDate().withDayOfMonth(1).atStartOfDay() val startOfNextMonth = startOfMonth.plusMonths(1) return MonthRange(startOfMonth, startOfNextMonth) } - fun monthRange(year: Int, month: Int, zoneId: ZoneId = ZoneId.of("Asia/Seoul")): MonthRange { + fun monthRange(year: Int, month: Int, zoneId: ZoneId = ZONE): MonthRange { val ym = YearMonth.of(year, month) val startZ = ym.atDay(1).atStartOfDay(zoneId) val nextStartZ = startZ.plusMonths(1) return MonthRange(startZ.toLocalDateTime(), nextStartZ.toLocalDateTime()) } + + fun thisWeekRange(today: LocalDate = LocalDate.now(ZONE)) : WeekRange { + val startOfWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) + val endOfWeek = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) + return WeekRange(startOfWeek, endOfWeek) + } } \ No newline at end of file From 66957b115e0491b134e88914eaa4f4687b2f1ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 27 Oct 2025 13:58:44 +0900 Subject: [PATCH 250/470] =?UTF-8?q?feat=20:=20=EC=98=A4=EB=8A=98=EB=A1=9C?= =?UTF-8?q?=EB=B6=80=ED=84=B0=EC=9D=98=20=EC=A0=84=EC=B2=B4=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20api=20?= =?UTF-8?q?=EC=A0=9C=EC=9E=91=20#52?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/controller/user/SessionController.kt | 10 ++++++++++ .../session/dto/response/SessionCardInfo.kt | 16 ++++++++++++++++ .../domain/session/facade/SessionFacade.kt | 4 ++++ .../session/repository/SessionRepository.kt | 12 ++++++++++++ .../domain/session/service/SessionService.kt | 15 +++++++++++++++ .../onku/backend/global/time/TimeRangeUtil.kt | 4 ++++ 6 files changed, 61 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/response/SessionCardInfo.kt 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 index 8d656b9..7eab0e1 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt @@ -2,6 +2,7 @@ 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.SessionCardInfo import onku.backend.domain.session.dto.response.SessionAboutAbsenceResponse import onku.backend.domain.session.dto.response.ThisWeekSessionInfo import onku.backend.domain.session.facade.SessionFacade @@ -35,4 +36,13 @@ class SessionController( fun showThisWeekSessionInfo() : ResponseEntity>> { return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showThisWeekSessionInfo())) } + + @GetMapping("/after/today") + @Operation( + summary = "전체 세션 정보 조회", + description = "오늘 부터의 전체 세션 정보를 시간순으로 조회합니다." + ) + fun showUpcomingSessionCards() : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showUpcomingSessionCards())) + } } \ No newline at end of file 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..cf3e179 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/SessionCardInfo.kt @@ -0,0 +1,16 @@ +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? +) diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index 83972e4..1fed3fe 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -116,4 +116,8 @@ class SessionFacade( fun showThisWeekSessionInfo(): List { return sessionService.getThisWeekSession() } + + fun showUpcomingSessionCards(): List { + return sessionService.getUpcomingSessionCards() + } } \ 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 index 16673b5..a9f30d5 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -39,6 +39,18 @@ interface SessionRepository : CrudRepository { @Param("restCategory") restCategory: SessionCategory = SessionCategory.REST ): List + @Query( + """ + SELECT s + FROM Session s + WHERE s.startDate >= :today + ORDER BY s.startDate ASC + """ + ) + fun findUpcomingSessionsOrderByStartDate( + @Param("today") today: LocalDate + ): List + @Query(""" SELECT s diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index 128a0a4..4f23a20 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -5,6 +5,7 @@ import onku.backend.domain.session.Session 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.SessionRepository import onku.backend.global.exception.CustomException @@ -77,6 +78,7 @@ class SessionService( } } + @Transactional(readOnly = true) fun getThisWeekSession(): List { val range = TimeRangeUtil.thisWeekRange() return sessionRepository.findThisWeekSunToSat(range.startOfWeek, range.endOfWeek).map { @@ -91,4 +93,17 @@ class SessionService( ) } } + + fun getUpcomingSessionCards(): List { + val today = TimeRangeUtil.todayDate() + return sessionRepository.findUpcomingSessionsOrderByStartDate(today) + .map { session -> + SessionCardInfo( + sessionId = session.id!!, + sessionCategory = session.category, + title = session.title, + startDate = session.startDate + ) + } + } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt b/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt index b7e88f6..e9e86d1 100644 --- a/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt +++ b/src/main/kotlin/onku/backend/global/time/TimeRangeUtil.kt @@ -33,4 +33,8 @@ object TimeRangeUtil { val endOfWeek = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) return WeekRange(startOfWeek, endOfWeek) } + + fun todayDate() : LocalDate { + return LocalDate.now(ZONE) + } } \ No newline at end of file From 571a62c9633bcd0b42cea187a5c50e11b7cb2f9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 28 Oct 2025 01:06:54 +0900 Subject: [PATCH 251/470] =?UTF-8?q?refactor=20:=20=EC=98=A4=EB=8A=98?= =?UTF-8?q?=EB=B6=80=ED=84=B0=EA=B0=80=20=EC=95=84=EB=8B=88=EB=9D=BC=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=84=B8=EC=85=98=EC=9D=84=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=9D=BC=EC=9E=90=20=EC=88=9C=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20#52?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/controller/user/SessionController.kt | 6 +++--- .../onku/backend/domain/session/facade/SessionFacade.kt | 4 ++-- .../backend/domain/session/repository/SessionRepository.kt | 5 +---- .../onku/backend/domain/session/service/SessionService.kt | 5 ++--- 4 files changed, 8 insertions(+), 12 deletions(-) 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 index 7eab0e1..ff5679e 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt @@ -40,9 +40,9 @@ class SessionController( @GetMapping("/after/today") @Operation( summary = "전체 세션 정보 조회", - description = "오늘 부터의 전체 세션 정보를 시간순으로 조회합니다." + description = "전체 세션 정보를 시간순으로 조회합니다." ) - fun showUpcomingSessionCards() : ResponseEntity>> { - return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showUpcomingSessionCards())) + fun showAllSessionCards() : ResponseEntity>> { + return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showAllSessionCards())) } } \ 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 index 1fed3fe..74b1548 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -117,7 +117,7 @@ class SessionFacade( return sessionService.getThisWeekSession() } - fun showUpcomingSessionCards(): List { - return sessionService.getUpcomingSessionCards() + fun showAllSessionCards(): List { + return sessionService.getAllSessionsOrderByStartDate() } } \ 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 index a9f30d5..04969de 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -43,13 +43,10 @@ interface SessionRepository : CrudRepository { """ SELECT s FROM Session s - WHERE s.startDate >= :today ORDER BY s.startDate ASC """ ) - fun findUpcomingSessionsOrderByStartDate( - @Param("today") today: LocalDate - ): List + fun findAllSessionsOrderByStartDate(): List @Query(""" diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index 4f23a20..67bdf4e 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -94,9 +94,8 @@ class SessionService( } } - fun getUpcomingSessionCards(): List { - val today = TimeRangeUtil.todayDate() - return sessionRepository.findUpcomingSessionsOrderByStartDate(today) + fun getAllSessionsOrderByStartDate(): List { + return sessionRepository.findAllSessionsOrderByStartDate() .map { session -> SessionCardInfo( sessionId = session.id!!, From ded391ef82fedf1b801e841e8398bf981e86d3be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 28 Oct 2025 10:48:46 +0900 Subject: [PATCH 252/470] =?UTF-8?q?hotfix=20:=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=88=98=EC=A0=95(=EB=AA=A9=EC=9A=94?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B4=ED=9B=84=EC=9D=B4=EB=A9=B4=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EB=9C=A8=EA=B2=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index d5d77f6..54f5939 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -30,7 +30,7 @@ class AbsenceFacade( sessionValidator.isPastSession(session) -> { throw CustomException(ErrorCode.SESSION_PAST) } - sessionValidator.isImminentSession(session) -> { + !sessionValidator.isImminentSession(session) -> { throw CustomException(ErrorCode.SESSION_IMMINENT) } sessionValidator.isRestSession(session) -> { From d6178ee96e577f87fb699932f119b1e53fd6105a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 28 Oct 2025 14:01:15 +0900 Subject: [PATCH 253/470] =?UTF-8?q?feat:=20memberProfile=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=97=90=EC=84=9C=20profile=20image=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=8F=84=20=EA=B0=80=EC=A0=B8=EC=99=80=EC=84=9C=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20response=20?= =?UTF-8?q?=EA=B0=92=20=EC=88=98=EC=A0=95=20#64?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/controller/MemberController.kt | 2 +- .../onku/backend/domain/member/dto/MemberProfileResponse.kt | 3 ++- .../onku/backend/domain/member/service/MemberProfileService.kt | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt index e78d854..a5a868c 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt @@ -45,7 +45,7 @@ class MemberController( @GetMapping("/profile/summary") @Operation( summary = "내 프로필 요약 조회", - description = "현재 로그인한 회원의 이름(name), 파트(part), 상벌점 총합(totalPoints) 반환" + description = "현재 로그인한 회원의 이름(name), 파트(part), 상벌점 총합(totalPoints), 프로필 이미지(profileImage) 반환" ) fun getMyProfileSummary( @CurrentMember member: Member diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt index 23fc51f..1c5c711 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt @@ -5,5 +5,6 @@ import onku.backend.domain.member.enums.Part data class MemberProfileResponse( val name: String?, val part: Part, - val totalPoints: Long + val totalPoints: Long, + val profileImage: String? ) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index 44f01ad..7d1f481 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -80,7 +80,8 @@ class MemberProfileService( return MemberProfileResponse( name = profile.name, part = profile.part, - totalPoints = total + totalPoints = total, + profileImage = profile.profileImage ) } From ab298cadd27f7f411e27c210c198e28e08d68aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 28 Oct 2025 23:24:18 +0900 Subject: [PATCH 254/470] =?UTF-8?q?refactor=20:=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EC=A0=9C=EA=B1=B0(=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=82=AC=ED=95=AD)=20#66?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../absence/controller/AbsenceController.kt | 9 +++------ .../domain/absence/facade/AbsenceFacade.kt | 11 ++++------ .../repository/AbsenceReportRepository.kt | 7 ++----- .../domain/absence/service/AbsenceService.kt | 20 +++++++++---------- 4 files changed, 18 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt index f829e1a..1a2fac8 100644 --- a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt +++ b/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt @@ -8,7 +8,6 @@ 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.page.PageResponse import onku.backend.global.response.SuccessResponse import onku.backend.global.s3.dto.GetPreSignedUrlDto import org.springframework.http.ResponseEntity @@ -29,10 +28,8 @@ class AbsenceController( @GetMapping("") @Operation(summary = "불참 사유서 제출내역 조회", description = "내가 낸 불참 사유서 제출내역을 조회합니다.") - fun getMyAbsenceReport(@CurrentMember member: Member, - @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(absenceFacade.getMyAbsenceReport(member, safePage, size))) + 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/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index 54f5939..1792361 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -8,12 +8,10 @@ import onku.backend.domain.member.Member import onku.backend.domain.session.service.SessionService import onku.backend.global.exception.CustomException import onku.backend.global.exception.ErrorCode -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 @Component @@ -42,10 +40,9 @@ class AbsenceFacade( return GetPreSignedUrlDto(preSignedUrlDto.preSignedUrl) } - fun getMyAbsenceReport(member: Member, page: Int, size: Int): PageResponse { - val pageRequest = PageRequest.of(page, size) - val absenceReportPage = absenceService.getMyAbsenceReports(member, pageRequest) - val responses = absenceReportPage.map { v -> + fun getMyAbsenceReport(member: Member): List { + val absenceReportList = absenceService.getMyAbsenceReports(member) + val responses = absenceReportList.map { v -> GetMyAbsenceReportResponse( absenceReportId = v.absenceReportId, absenceType = v.absenceType, @@ -55,7 +52,7 @@ class AbsenceFacade( sessionStartDate = v.sessionStartDate ) } - return PageResponse.from(responses) + return responses } } \ 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 index 98dc435..c2f2221 100644 --- a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -3,8 +3,6 @@ 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.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 @@ -25,9 +23,8 @@ interface AbsenceReportRepository : JpaRepository { """ ) fun findMyAbsenceReports( - @Param("member") member: Member, - pageable: Pageable - ): Page + @Param("member") member: Member + ): List @Query(""" select ar from AbsenceReport ar diff --git a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt index a4e94e4..76e1e49 100644 --- a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -6,8 +6,6 @@ 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 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 @@ -37,16 +35,16 @@ class AbsenceService( } @Transactional(readOnly = true) - fun getMyAbsenceReports(member: Member, pageable: Pageable): Page { - val page = absenceReportRepository.findMyAbsenceReports(member, pageable) - return page.map { v -> + fun getMyAbsenceReports(member: Member): List { + val absenceReports = absenceReportRepository.findMyAbsenceReports(member) + return absenceReports.map { a -> GetMyAbsenceReportResponse( - absenceReportId = v.getAbsenceReportId(), - absenceType = v.getAbsenceType(), - absenceReportApproval = v.getAbsenceReportApproval(), - submitDateTime = v.getSubmitDateTime(), - sessionTitle = v.getSessionTitle(), - sessionStartDate = v.getSessionStartDateTime() + absenceReportId = a.getAbsenceReportId(), + absenceType = a.getAbsenceType(), + absenceReportApproval = a.getAbsenceReportApproval(), + submitDateTime = a.getSubmitDateTime(), + sessionTitle = a.getSessionTitle(), + sessionStartDate = a.getSessionStartDateTime() ) } } From 93dd2ddc4f90b6e0aa4a681f283c9f982b5110bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 29 Oct 2025 00:16:41 +0900 Subject: [PATCH 255/470] =?UTF-8?q?feat:=20=EB=AA=A8=EB=93=A0=20Role?= =?UTF-8?q?=EC=97=90=20GUEST=20=EA=B6=8C=ED=95=9C=20=EC=B6=94=EA=B0=80=20#?= =?UTF-8?q?68?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/member/enums/Role.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/enums/Role.kt b/src/main/kotlin/onku/backend/domain/member/enums/Role.kt index 108ca9d..7888042 100644 --- a/src/main/kotlin/onku/backend/domain/member/enums/Role.kt +++ b/src/main/kotlin/onku/backend/domain/member/enums/Role.kt @@ -15,9 +15,9 @@ enum class Role { fun authorities(): List = when (this) { GUEST -> listOf("GUEST") - USER -> listOf("USER") - STAFF -> listOf("STAFF", "USER") - MANAGEMENT -> listOf("MANAGEMENT", "STAFF", "USER") - EXECUTIVE -> listOf("EXECUTIVE", "MANAGEMENT", "STAFF", "USER") + USER -> listOf("USER", "GUEST") + STAFF -> listOf("STAFF", "USER", "GUEST") + MANAGEMENT -> listOf("MANAGEMENT", "STAFF", "USER", "GUEST") + EXECUTIVE -> listOf("EXECUTIVE", "MANAGEMENT", "STAFF", "USER", "GUEST") } } From 55557195f66266721090d965cfc5ebd9f5492ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 29 Oct 2025 00:17:29 +0900 Subject: [PATCH 256/470] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=88=98=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20#68?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.kt | 28 +++----------- .../member/service/MemberProfileService.kt | 38 +++++++++++++++++-- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt index a5a868c..e165977 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt @@ -12,6 +12,7 @@ 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.GetPreSignedUrlDto +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 @@ -54,35 +55,18 @@ class MemberController( return SuccessResponse.ok(body) } - @PatchMapping("/profile/image") - @Operation( - summary = "내 프로필 이미지 수정", - description = "이미지 URL을 입력받아 MemberProfile.profileImage를 수정하고 최종 URL을 반환" - ) - fun updateMyProfileImage( - @CurrentMember member: Member, - @RequestBody @Valid req: UpdateProfileImageRequest - ): SuccessResponse { - val body = memberProfileService.updateProfileImage(member, req) - return SuccessResponse.ok(body) - } - @GetMapping("/profile/image/url") @Operation( summary = "프로필 이미지 업로드용 Presigned URL 발급", - description = "업로드 URL 반환" + description = "URL 발급과 동시에 DB에 프로필 이미지 key를 저장 or 갱신하며, 이전 이미지 삭제용 URL도 함께 반환" ) fun profileImagePostUrl( @CurrentMember member: Member, @RequestParam fileName: String - ): ResponseEntity> { - val dto = s3Service.getPostS3Url( - memberId = member.id!!, - filename = fileName, - folderName = FolderName.MEMBER_PROFILE.name, - option = UploadOption.IMAGE - ) - return ResponseEntity.ok(SuccessResponse.ok(GetPreSignedUrlDto(preSignedUrl = dto.preSignedUrl))) + ): ResponseEntity> { + + val dto = memberProfileService.issueProfileImageUploadUrl(member, fileName) + return ResponseEntity.ok(SuccessResponse.ok(dto)) } @Operation( diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index 7d1f481..861aeea 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -9,6 +9,10 @@ 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.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.Service import org.springframework.transaction.annotation.Transactional @@ -18,7 +22,8 @@ class MemberProfileService( private val memberProfileRepository: MemberProfileRepository, private val memberRepository: MemberRepository, private val memberService: MemberService, - private val memberPointHistoryRepository: MemberPointHistoryRepository + private val memberPointHistoryRepository: MemberPointHistoryRepository, + private val s3Service: S3Service ) { fun submitOnboarding(member: Member, req: OnboardingRequest): OnboardingResponse { if (member.hasInfo) { // 이미 온보딩 완료된 사용자 차단 @@ -98,10 +103,35 @@ class MemberProfileService( ) } - fun updateProfileImage(member: Member, req: UpdateProfileImageRequest): UpdateProfileImageResponse { + @Transactional + fun issueProfileImageUploadUrl(member: Member, fileName: String): GetUpdateAndDeleteUrlDto { + val signedUrlDto = s3Service.getPostS3Url( + memberId = member.id!!, + filename = fileName, + folderName = FolderName.MEMBER_PROFILE.name, + option = UploadOption.IMAGE + ) + + val oldKey = submitProfileImage(member, signedUrlDto.key) + + val oldDeletePreSignedUrl = if (!oldKey.isNullOrBlank()) { + s3Service.getDeleteS3Url(oldKey).preSignedUrl + } else { + "" + } + + return GetUpdateAndDeleteUrlDto( + newUrl = signedUrlDto.preSignedUrl, + oldUrl = oldDeletePreSignedUrl + ) + } + + fun submitProfileImage(member: Member, newKey: String): String? { val profile = memberProfileRepository.findById(member.id!!) .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } - profile.profileImage = req.imageUrl - return UpdateProfileImageResponse(profileImageUrl = profile.profileImage!!) + + val old = profile.profileImage + profile.profileImage = newKey + return old } } \ No newline at end of file From a920b62df0287e4849a964266891f088e1cde7e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 29 Oct 2025 00:27:18 +0900 Subject: [PATCH 257/470] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20=EC=A1=B0=ED=9A=8C=20API=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8F=84=20=ED=82=A4=EB=A1=9C=EB=B6=80=ED=84=B0=20PresignedUrl?= =?UTF-8?q?=20=EB=B0=9C=EA=B8=89=ED=95=B4=EC=84=9C=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#68?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/member/service/MemberProfileService.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index 861aeea..2f66860 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -82,14 +82,18 @@ class MemberProfileService( 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( name = profile.name, part = profile.part, totalPoints = total, - profileImage = profile.profileImage + profileImage = url ) } + @Transactional(readOnly = true) fun getProfileBasics(member: Member): MemberProfileBasicsResponse { val profile = memberProfileRepository.findById(member.id!!) From 518974955b716e4bc1eb82893692a11095f6c3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 29 Oct 2025 01:02:28 +0900 Subject: [PATCH 258/470] =?UTF-8?q?refactor=20:=20content=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=ED=83=80=EC=9E=85=20=EB=B0=94=EA=BE=B8=EA=B8=B0(?= =?UTF-8?q?=EB=8C=80=EC=9A=A9=EB=9F=89=20=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=8F=84=20=EB=93=A4=EC=96=B4=EA=B0=88=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EA=B2=8C=EB=81=94)=20#63?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/session/SessionDetail.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt b/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt index 900a8a7..26d2de4 100644 --- a/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt +++ b/src/main/kotlin/onku/backend/domain/session/SessionDetail.kt @@ -20,7 +20,8 @@ class SessionDetail( @Column(name = "end_time", nullable = false) var endTime: LocalTime, - @Column(name = "content", nullable = false) + @Lob + @Column(name = "content", nullable = false, columnDefinition = "TEXT") var content: String, ) : BaseEntity() { } \ No newline at end of file From 1cbddada4cca064a4e65dabf2db416a147ace2e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 29 Oct 2025 01:03:07 +0900 Subject: [PATCH 259/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20#63?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/absence/facade/AbsenceFacade.kt | 8 ++++---- .../attendance/service/AttendanceFinalizeService.kt | 4 ++-- .../onku/backend/domain/session/SessionErrorCode.kt | 6 +++++- .../kotlin/onku/backend/global/exception/ErrorCode.kt | 4 ---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index d5d77f6..7e79a88 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -5,9 +5,9 @@ import onku.backend.domain.absence.dto.response.GetMyAbsenceReportResponse import onku.backend.domain.absence.service.AbsenceService import onku.backend.domain.session.validator.SessionValidator import onku.backend.domain.member.Member +import onku.backend.domain.session.SessionErrorCode import onku.backend.domain.session.service.SessionService import onku.backend.global.exception.CustomException -import onku.backend.global.exception.ErrorCode import onku.backend.global.page.PageResponse import onku.backend.global.s3.dto.GetPreSignedUrlDto import onku.backend.global.s3.enums.FolderName @@ -28,13 +28,13 @@ class AbsenceFacade( //세션 검증 when { sessionValidator.isPastSession(session) -> { - throw CustomException(ErrorCode.SESSION_PAST) + throw CustomException(SessionErrorCode.SESSION_PAST) } sessionValidator.isImminentSession(session) -> { - throw CustomException(ErrorCode.SESSION_IMMINENT) + throw CustomException(SessionErrorCode.SESSION_IMMINENT) } sessionValidator.isRestSession(session) -> { - throw CustomException(ErrorCode.INVALID_SESSION) + throw CustomException(SessionErrorCode.INVALID_SESSION) } } val preSignedUrlDto = s3Service.getPostS3Url(member.id!!, submitAbsenceReportRequest.fileName, FolderName.ABSENCE.name, UploadOption.FILE) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt index 0295767..4415520 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt @@ -12,10 +12,10 @@ 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 onku.backend.global.exception.ErrorCode import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -36,7 +36,7 @@ class AttendanceFinalizeService( fun finalizeSession(sessionId: Long) { val now = LocalDateTime.now(clock) val session = sessionRepository.findById(sessionId) - .orElseThrow { CustomException(ErrorCode.SESSION_NOT_FOUND) } + .orElseThrow { CustomException(SessionErrorCode.SESSION_NOT_FOUND) } if (session.attendanceFinalized) return val startDateTime = SessionTimeUtil.startDateTime(session) diff --git a/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt b/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt index d03db31..2f31dfc 100644 --- a/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt +++ b/src/main/kotlin/onku/backend/domain/session/SessionErrorCode.kt @@ -9,5 +9,9 @@ enum class SessionErrorCode( override val status: HttpStatus ) : ApiErrorCode { SESSION_DETAIL_NOT_FOUND("SESSION_DETAIL404", "세션 상세정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - SESSION_IMAGE_NOT_FOUND("SESSION_IMAGE404", "세션 이미지를 찾을 수 없습니다.", 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/global/exception/ErrorCode.kt b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt index 7089415..a247376 100644 --- a/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/exception/ErrorCode.kt @@ -16,9 +16,5 @@ enum class ErrorCode( PARAMETER_GRAMMAR_ERROR("COMMON422", "파라미터 문법 에러입니다.", HttpStatus.UNPROCESSABLE_ENTITY), INVALID_FILE_EXTENSION("S3001", "올바르지 않은 파일 확장자 입니다.", HttpStatus.BAD_REQUEST), FCM_ACCESS_TOKEN_FAIL("alarm001", "FCM 액세스 토큰 발급 중에 오류가 발생했습니다.", HttpStatus.BAD_REQUEST), - 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), SQL_INTEGRITY_VIOLATION("sql001", "무결성 제약조건을 위반하였습니다.", HttpStatus.BAD_REQUEST); } From 3608f705a0f28ff23cb70a1b151ecd63c3ae3473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 29 Oct 2025 01:03:45 +0900 Subject: [PATCH 260/470] =?UTF-8?q?feat=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD=20=EC=A1=B0=ED=9A=8C=20api?= =?UTF-8?q?=20=EC=A0=9C=EC=9E=91=20#63?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/user/SessionController.kt | 13 ++++++++ .../dto/response/GetSessionNoticeResponse.kt | 27 ++++++++++++++++ .../domain/session/facade/SessionFacade.kt | 29 ++++++++++++++++- .../repository/SessionImageRepository.kt | 10 ++++++ .../session/repository/SessionRepository.kt | 9 ++++++ .../session/service/SessionDetailService.kt | 3 +- .../session/service/SessionNoticeService.kt | 32 +++++++++++++++++++ .../domain/session/service/SessionService.kt | 4 +-- 8 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/response/GetSessionNoticeResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/session/service/SessionNoticeService.kt 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 index ff5679e..4775a4b 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt @@ -2,6 +2,7 @@ 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.GetSessionNoticeResponse import onku.backend.domain.session.dto.response.SessionCardInfo import onku.backend.domain.session.dto.response.SessionAboutAbsenceResponse import onku.backend.domain.session.dto.response.ThisWeekSessionInfo @@ -9,6 +10,7 @@ import onku.backend.domain.session.facade.SessionFacade import onku.backend.global.response.SuccessResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -45,4 +47,15 @@ class SessionController( 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/response/GetSessionNoticeResponse.kt b/src/main/kotlin/onku/backend/domain/session/dto/response/GetSessionNoticeResponse.kt new file mode 100644 index 0000000..dfa2805 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/GetSessionNoticeResponse.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.LocalDate +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 +) diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index 74b1548..71e3449 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -9,6 +9,7 @@ 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.page.PageResponse import onku.backend.global.s3.dto.GetPreSignedUrlDto @@ -23,7 +24,8 @@ class SessionFacade( private val sessionService: SessionService, private val sessionDetailService: SessionDetailService, private val sessionImageService : SessionImageService, - private val s3Service: S3Service + private val s3Service: S3Service, + private val sessionNoticeService: SessionNoticeService ) { fun showSessionAboutAbsence(): List { return sessionService.getUpcomingSessionsForAbsence() @@ -120,4 +122,29 @@ class SessionFacade( 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 presign = s3Service.getGetS3Url(0L, img.url) + SessionImageDto( + sessionImageId = img.id!!, + sessionImagePreSignedUrl = presign.preSignedUrl + ) + } + + 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 + ) + } } \ 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 index dda0a16..63e5198 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt @@ -1,8 +1,18 @@ package onku.backend.domain.session.repository import onku.backend.domain.session.SessionImage +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 } \ 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 index 04969de..5ea1950 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -11,6 +11,7 @@ import org.springframework.data.repository.query.Param import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime +import java.util.* interface SessionRepository : CrudRepository { @@ -109,4 +110,12 @@ interface SessionRepository : CrudRepository { @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? } diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt index 9396385..965c8b6 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionDetailService.kt @@ -13,7 +13,6 @@ import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import onku.backend.domain.session.util.SessionTimeUtil.absentBoundary -import onku.backend.global.exception.ErrorCode @Service class SessionDetailService( @@ -46,7 +45,7 @@ class SessionDetailService( sessionRepository.save(session) val runAt = absentBoundary(session, ABSENT_START_MINUTES) - val sessionId = session.id ?: throw CustomException(ErrorCode.SESSION_NOT_FOUND) + val sessionId = session.id ?: throw CustomException(SessionErrorCode.SESSION_NOT_FOUND) applicationEventPublisher.publishEvent( FinalizeEvent(sessionId, runAt) 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..97f7494 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionNoticeService.kt @@ -0,0 +1,32 @@ +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 onku.backend.global.s3.service.S3Service +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SessionNoticeService( + private val sessionRepository: SessionRepository, + private val sessionImageRepository: SessionImageRepository, + private val s3Service: S3Service +) { + @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 index 67bdf4e..4249af0 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -2,6 +2,7 @@ package onku.backend.domain.session.service 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 @@ -9,7 +10,6 @@ import onku.backend.domain.session.dto.response.SessionCardInfo import onku.backend.domain.session.dto.response.ThisWeekSessionInfo import onku.backend.domain.session.repository.SessionRepository import onku.backend.global.exception.CustomException -import onku.backend.global.exception.ErrorCode import onku.backend.global.time.TimeRangeUtil import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -46,7 +46,7 @@ class SessionService( @Transactional(readOnly = true) fun getById(id : Long) : Session { - return sessionRepository.findByIdOrNull(id) ?: throw CustomException(ErrorCode.SESSION_NOT_FOUND) + return sessionRepository.findByIdOrNull(id) ?: throw CustomException(SessionErrorCode.SESSION_NOT_FOUND) } @Transactional From 47add25b9c9664437f48f530c32b241747d46203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 29 Oct 2025 01:10:18 +0900 Subject: [PATCH 261/470] =?UTF-8?q?refactor=20:=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20#63?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/controller/MemberController.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt index 7852cf5..0b930dd 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt @@ -12,7 +12,6 @@ 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 onku.backend.global.s3.service.S3Service import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -22,7 +21,6 @@ import org.springframework.web.bind.annotation.* @Tag(name = "회원 API", description = "온보딩 및 프로필 관련 API") class MemberController( private val memberProfileService: MemberProfileService, - private val s3Service: S3Service, private val memberService: MemberService ) { From ad94e96e07af7cda4c17a73da2ee051ad95021f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 31 Oct 2025 14:47:13 +0900 Subject: [PATCH 262/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=ED=95=9C=20=EC=82=AC=EB=9E=8C=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20dto=20=EC=B6=94=EA=B0=80=20#71?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/dto/WeeklyAttendanceSummary.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/dto/WeeklyAttendanceSummary.kt 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 From 8ddfb2617b77895c15ed8552e2eaeff7ca8e6248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 31 Oct 2025 14:47:53 +0900 Subject: [PATCH 263/470] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=EC=9D=98=20?= =?UTF-8?q?=EC=B6=9C=EC=84=9D=20=EC=8A=A4=EC=BA=94=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EA=B0=92=EC=97=90=20=EC=83=88=EB=A1=9C=EC=9A=B4=20dto=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=8F=84=20=EC=B6=94=EA=B0=80=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20#71?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/attendance/dto/AttendanceResponse.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt index d182c04..206a015 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceResponse.kt @@ -8,5 +8,6 @@ data class AttendanceResponse( val memberName: String, val sessionId: Long, val state: AttendancePointType, - val scannedAt: LocalDateTime + val scannedAt: LocalDateTime, + val thisWeekSummary: WeeklyAttendanceSummary ) \ No newline at end of file From 2e45b20227e4b9e1c4cebd84d70e7a1ad3d4c997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 31 Oct 2025 14:48:39 +0900 Subject: [PATCH 264/470] =?UTF-8?q?feat:=20=EC=83=81=ED=83=9C=EB=B3=84=20?= =?UTF-8?q?=EC=9D=B8=EC=9B=90=EC=88=98=20=EA=B3=84=EC=82=B0=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=20#71?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/AttendanceRepository.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt index ef2faf2..905a010 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt @@ -1,12 +1,19 @@ 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 @@ -41,4 +48,15 @@ interface AttendanceRepository : CrudRepository { 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 } From f1a1d09696cc7074c13f41a103cb8ebf02e14690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 31 Oct 2025 14:50:02 +0900 Subject: [PATCH 265/470] =?UTF-8?q?feat:=20=ED=95=B4=EB=8B=B9=EC=A3=BC?= =?UTF-8?q?=EC=B0=A8=EC=9D=98=20=EC=B6=9C=EC=84=9D=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=B3=84=20=EC=9D=B8=EC=9B=90=EC=88=98=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20scanAndRecordBy=20=ED=95=A8=EC=88=98=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=8F=84=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20#71?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/service/AttendanceService.kt | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 3bc2da8..316eb37 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -17,6 +17,7 @@ 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 @@ -56,6 +57,40 @@ class AttendanceService( 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 -> 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) @@ -79,7 +114,7 @@ class AttendanceService( val lateThreshold = startDateTime.plusMinutes(AttendancePolicy.LATE_WINDOW_MINUTES) val state = when { - now.isAfter(lateThreshold) -> AttendancePointType.ABSENT + now.isAfter(lateThreshold) -> AttendancePointType.ABSENT !now.isBefore(startDateTime) -> AttendancePointType.LATE else -> AttendancePointType.PRESENT } @@ -109,12 +144,15 @@ class AttendanceService( throw CustomException(AttendanceErrorCode.ATTENDANCE_ALREADY_RECORDED) } + val weeklySummary = getThisWeekSummary() + return AttendanceResponse( memberId = memberId, memberName = memberName, sessionId = session.id!!, state = state, - scannedAt = now + scannedAt = now, + thisWeekSummary = weeklySummary ) } } \ No newline at end of file From aee5bf500ce4731a3929b5fa2419788adcd56a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 31 Oct 2025 14:50:23 +0900 Subject: [PATCH 266/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=9D=B8?= =?UTF-8?q?=EC=9B=90=20=EC=A0=95=EB=B3=B4=20=EB=B0=98=ED=99=98=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#71?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/controller/AttendanceController.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt b/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt index 512f09c..56cca14 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag import onku.backend.domain.attendance.dto.AttendanceRequest import onku.backend.domain.attendance.dto.AttendanceResponse import onku.backend.domain.attendance.dto.AttendanceTokenResponse +import onku.backend.domain.attendance.dto.WeeklyAttendanceSummary import onku.backend.domain.attendance.facade.AttendanceFacade import onku.backend.domain.attendance.service.AttendanceService import onku.backend.domain.member.Member @@ -30,8 +31,19 @@ class AttendanceController( } @PostMapping("/scan") - @Operation(summary = "출석 스캔 [MANAGEMENT]", description = "열린 세션 자동 선택 → 토큰 검증 & 소비 → insert") + @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 = "금주 출석 요약 조회", + description = "이번 주 기간 내 출석/조퇴/지각/결석 인원 반환. 출석 페이지 첫 로딩 때 호출해주시면 됩니다!" + ) + fun getThisWeekSummary(): SuccessResponse { + val summary = attendanceService.getThisWeekSummary() + return SuccessResponse.ok(summary) + } } From 0d53a60656c0936ba3466883fc8ff4f905316e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 31 Oct 2025 15:28:13 +0900 Subject: [PATCH 267/470] =?UTF-8?q?feat:=20=EC=9D=91=EB=8B=B5=20dto=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B6=9C=EC=84=9D=20=EB=B6=88=EA=B0=80=20=EC=9D=B4?= =?UTF-8?q?=EC=9C=A0=20enum=20=EC=B6=94=EA=B0=80=20#73?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/dto/AttendanceAvailabilityResponse.kt | 8 ++++++++ .../attendance/enums/AttendanceAvailabilityReason.kt | 6 ++++++ 2 files changed, 14 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/dto/AttendanceAvailabilityResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/attendance/enums/AttendanceAvailabilityReason.kt 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/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 From 402f08f2e1969a8c9baf246bf6979ca48baa18cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 31 Oct 2025 15:28:59 +0900 Subject: [PATCH 268/470] =?UTF-8?q?feat:=20=ED=95=B4=EB=8B=B9=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=EC=9D=98=20=EC=97=B4=EB=A6=B0=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20=EC=B6=9C=EC=84=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#73?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/service/AttendanceService.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 316eb37..12fed5f 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -5,6 +5,7 @@ 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 @@ -155,4 +156,28 @@ class AttendanceService( 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 From 9e111cbf5ad7e360c044566c5a221973d9cab1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 31 Oct 2025 15:29:23 +0900 Subject: [PATCH 269/470] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EB=B3=84?= =?UTF-8?q?=20=EC=97=B4=EB=A6=B0=20=EC=84=B8=EC=85=98=EC=9D=98=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=EC=97=AC=EB=B6=80=20=EB=B0=98=ED=99=98=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#73?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/controller/AttendanceController.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt b/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt index 56cca14..a0603ca 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt @@ -2,10 +2,7 @@ package onku.backend.domain.attendance.controller import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag -import onku.backend.domain.attendance.dto.AttendanceRequest -import onku.backend.domain.attendance.dto.AttendanceResponse -import onku.backend.domain.attendance.dto.AttendanceTokenResponse -import onku.backend.domain.attendance.dto.WeeklyAttendanceSummary +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 @@ -46,4 +43,14 @@ class AttendanceController( val summary = attendanceService.getThisWeekSummary() return SuccessResponse.ok(summary) } + + @GetMapping("/availability") + @Operation( + summary = "지금 출석 가능 여부 확인 [USER]", + description = "열린 세션이 있는지 확인하고, 해당 세션에 이미 출석했는지 판단하여 가능 여부를 반환합니다." + ) + fun checkAvailability(@CurrentMember member: Member): SuccessResponse { + val body = attendanceService.checkAvailabilityFor(member) + return SuccessResponse.ok(body) + } } From 80b1d398a753ec3ff8f13af0fe1aaf8acf016ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 31 Oct 2025 15:52:28 +0900 Subject: [PATCH 270/470] =?UTF-8?q?feat:=20=EA=B6=8C=ED=95=9C=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EC=9C=84=ED=95=B4=20controller=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=8B=9C=ED=81=90=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=EB=8B=A8=EC=97=90=EC=84=9C=EC=9D=98=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=84=A4=EC=A0=95=20#73?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/AttendanceManagerController.kt | 37 +++++++++++++++++++ .../AttendanceMemberController.kt} | 31 +++++----------- .../global/auth/config/SecurityConfig.kt | 2 +- 3 files changed, 47 insertions(+), 23 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/controller/manager/AttendanceManagerController.kt rename src/main/kotlin/onku/backend/domain/attendance/controller/{AttendanceController.kt => user/AttendanceMemberController.kt} (53%) 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/AttendanceController.kt b/src/main/kotlin/onku/backend/domain/attendance/controller/user/AttendanceMemberController.kt similarity index 53% rename from src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt rename to src/main/kotlin/onku/backend/domain/attendance/controller/user/AttendanceMemberController.kt index a0603ca..a236694 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/controller/AttendanceController.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/controller/user/AttendanceMemberController.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.attendance.controller +package onku.backend.domain.attendance.controller.user import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag @@ -15,42 +15,29 @@ import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/attendance") @Tag(name = "출석 API") -class AttendanceController( +class AttendanceMemberController( private val attendanceService: AttendanceService, private val attendanceFacade: AttendanceFacade ) { + @PostMapping("/token") - @Operation(summary = "출석용 토큰 발급 [USER]", description = "15초 유효 + 프로필(이름/파트/학교/프사) 포함") + @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)) } - @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 = "금주 출석 요약 조회", - description = "이번 주 기간 내 출석/조퇴/지각/결석 인원 반환. 출석 페이지 첫 로딩 때 호출해주시면 됩니다!" - ) - fun getThisWeekSummary(): SuccessResponse { - val summary = attendanceService.getThisWeekSummary() - return SuccessResponse.ok(summary) - } - @GetMapping("/availability") @Operation( summary = "지금 출석 가능 여부 확인 [USER]", - description = "열린 세션이 있는지 확인하고, 해당 세션에 이미 출석했는지 판단하여 가능 여부를 반환합니다." + 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/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index 46e85aa..bc41cf4 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -43,7 +43,7 @@ class SecurityConfig( private val MANAGEMENT_ENDPOINT = arrayOf( // 경총 "/api/v1/kupick/manage/**", "/api/v1/points/manage/**", - "/api/v1/attendance/scan" + "/api/v1/attendance/manage/**" ) // private val EXECUTIVE = arrayOf("") // 회장단 From fc910a7d38071462037ab6d2b13a57d1903b61aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 1 Nov 2025 20:47:14 +0900 Subject: [PATCH 271/470] =?UTF-8?q?feat:=20S3Client=20Config=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20#75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle.kts b/build.gradle.kts index da9ca6a..37b5b7f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { 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 From 9fca65b2472e3dd2b4555322f433386e04446dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 1 Nov 2025 20:52:39 +0900 Subject: [PATCH 272/470] =?UTF-8?q?feat:=20=20S3Client=20Config=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20#75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/s3/config/S3DevConfig.kt | 28 ++++++++++++++++++ .../backend/global/s3/config/S3ProdConfig.kt | 29 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt b/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt index b83eac7..2e14a67 100644 --- a/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt +++ b/src/main/kotlin/onku/backend/global/s3/config/S3DevConfig.kt @@ -9,6 +9,11 @@ import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration +import software.amazon.awssdk.core.retry.RetryPolicy +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient +import software.amazon.awssdk.services.s3.S3Configuration +import java.time.Duration @Configuration @Profile("dev", "local", "test") @@ -29,4 +34,27 @@ class S3DevConfig( .region(Region.of(region)) .credentialsProvider(staticCreds()) .build() + + @Bean + fun s3Client(): S3Client { + val overrides = ClientOverrideConfiguration.builder() + .apiCallTimeout(Duration.ofSeconds(30)) + .apiCallAttemptTimeout(Duration.ofSeconds(20)) + .retryPolicy(RetryPolicy.builder().numRetries(3).build()) + .build() + + val s3cfg = S3Configuration.builder() + .checksumValidationEnabled(true) + .pathStyleAccessEnabled(false) + .build() + + val builder = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(staticCreds()) + .httpClient(UrlConnectionHttpClient.builder().build()) + .overrideConfiguration(overrides) + .serviceConfiguration(s3cfg) + + return builder.build() + } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt b/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt index 9685f4b..32561ad 100644 --- a/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt +++ b/src/main/kotlin/onku/backend/global/s3/config/S3ProdConfig.kt @@ -5,8 +5,14 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Profile import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration +import software.amazon.awssdk.core.retry.RetryPolicy +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.S3Configuration import software.amazon.awssdk.services.s3.presigner.S3Presigner +import java.time.Duration @Configuration @Profile("prod") @@ -19,4 +25,27 @@ class S3ProdConfig( .region(Region.of(region)) .credentialsProvider(DefaultCredentialsProvider.create()) .build() + + @Bean + fun s3Client(): S3Client { + val overrides = ClientOverrideConfiguration.builder() + .apiCallTimeout(Duration.ofSeconds(30)) + .apiCallAttemptTimeout(Duration.ofSeconds(20)) + .retryPolicy(RetryPolicy.builder().numRetries(3).build()) + .build() + + val s3cfg = S3Configuration.builder() + .checksumValidationEnabled(true) + .pathStyleAccessEnabled(false) + .build() + + val builder = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .httpClient(UrlConnectionHttpClient.builder().build()) + .overrideConfiguration(overrides) + .serviceConfiguration(s3cfg) + + return builder.build() + } } \ No newline at end of file From e6e871a3fa86cbf1f059410d69cb041963126a30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 1 Nov 2025 21:38:39 +0900 Subject: [PATCH 273/470] =?UTF-8?q?feat:=20FK=20=EC=A0=9C=EC=95=BD=20?= =?UTF-8?q?=EC=9C=84=EB=B0=98=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20=EC=97=B0=EA=B4=80=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EB=A0=88=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=EC=9A=A9=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20#75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/AbsenceReportRepository.kt | 5 +++ .../repository/AttendanceRepository.kt | 5 +++ .../backend/global/s3/service/S3Service.kt | 34 +++++++++++++++---- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt index c2f2221..304a2b0 100644 --- a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -4,6 +4,7 @@ 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 @@ -34,4 +35,8 @@ interface AbsenceReportRepository : JpaRepository { @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 } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt index 905a010..9d3d7a3 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt @@ -1,5 +1,6 @@ package onku.backend.domain.attendance.repository +import jakarta.transaction.Transactional import onku.backend.domain.attendance.Attendance import onku.backend.domain.attendance.enums.AttendancePointType import org.springframework.data.jpa.repository.Modifying @@ -59,4 +60,8 @@ interface AttendanceRepository : CrudRepository { @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/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt index 69adeed..65c1c55 100644 --- a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -7,9 +7,8 @@ import onku.backend.global.s3.enums.UploadOption import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import software.amazon.awssdk.services.s3.model.DeleteObjectRequest -import software.amazon.awssdk.services.s3.model.GetObjectRequest -import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.* import software.amazon.awssdk.services.s3.presigner.S3Presigner import software.amazon.awssdk.services.s3.presigner.model.DeleteObjectPresignRequest import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest @@ -22,7 +21,8 @@ import java.util.UUID class S3Service( @Value("\${cloud.aws.s3.bucket}") private val bucket : String, - private val s3Presigner: S3Presigner + private val s3Presigner: S3Presigner, + private val s3Client: S3Client ) { @Transactional(readOnly = true) fun getPostS3Url(memberId: Long, filename: String?, folderName : String, option : UploadOption): GetS3UrlDto { @@ -98,6 +98,30 @@ class S3Service( return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key) } + fun deleteObjectsNow(keys: List) { + val filtered = keys.asSequence().map(String::trim).filter(String::isNotEmpty).toList() + if (filtered.isEmpty()) return + + filtered.chunked(1000).forEach { chunk -> + val delete = Delete.builder() + .quiet(true) + .objects(chunk.map { ObjectIdentifier.builder().key(it).build() }) + .build() + + val req = DeleteObjectsRequest.builder() + .bucket(bucket) + .delete(delete) + .build() + + val resp = s3Client.deleteObjects(req) + val errors = resp.errors().orEmpty() + + if (errors.any { it.code() != "NoSuchKey" }) { + throw CustomException(ErrorCode.SERVER_UNTRACKED_ERROR) + } + } + } + private fun guessFileType(filename: String): String { val lower = filename.lowercase() return when { @@ -123,6 +147,4 @@ class S3Service( companion object { private val DEFAULT_EXPIRE: Duration = Duration.ofMinutes(10) } - - } \ No newline at end of file From ee5f8eddf418baaf0c12d8777998f5ac72902042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 1 Nov 2025 21:40:16 +0900 Subject: [PATCH 274/470] =?UTF-8?q?feat:=20SessionRepository=EC=97=90=20de?= =?UTF-8?q?tailId=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=84=B8=EC=85=98-?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=97=B0=EA=B2=B0=20=ED=95=B4=EC=A0=9C=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80=20#75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/repository/SessionRepository.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index 5ea1950..6825ab3 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -5,6 +5,7 @@ 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 @@ -118,4 +119,11 @@ interface SessionRepository : CrudRepository { 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 } From 3a93bdbe70cb30d498fd10b728928cf0e1a49d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 1 Nov 2025 21:41:28 +0900 Subject: [PATCH 275/470] =?UTF-8?q?feat:=20SessionImageRepository=EC=97=90?= =?UTF-8?q?=20detailId=20=EA=B8=B0=EB=B0=98=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/repository/SessionImageRepository.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt index 63e5198..618381a 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionImageRepository.kt @@ -1,6 +1,7 @@ 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 @@ -15,4 +16,18 @@ interface SessionImageRepository : CrudRepository { 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 From f2aaac5ea6ab7f5e8f48a36b5792e30b11163e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 1 Nov 2025 21:42:03 +0900 Subject: [PATCH 276/470] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/service/SessionService.kt | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index 4249af0..54fefcc 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -1,5 +1,9 @@ package onku.backend.domain.session.service +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +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 @@ -8,8 +12,11 @@ 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 @@ -24,7 +31,12 @@ import java.time.ZoneId class SessionService( private val sessionRepository: SessionRepository, private val sessionValidator: SessionValidator, - private val clock: Clock = Clock.system(ZoneId.of("Asia/Seoul")) + 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 { @@ -105,4 +117,32 @@ class SessionService( ) } } + + /** + * - 해당 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) + } } \ No newline at end of file From 1297ff367d3a27d6017021d2c75f4d5527e965f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 1 Nov 2025 21:42:28 +0900 Subject: [PATCH 277/470] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20facade?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20#75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/session/facade/SessionFacade.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index 71e3449..8e1072a 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -147,4 +147,7 @@ class SessionFacade( images = imageDtos ) } + fun deleteSession(sessionId: Long) { + sessionService.deleteCascade(sessionId) + } } \ No newline at end of file From 78f82a8b58df8266f4ccb43acad3f7c0baf313aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 1 Nov 2025 21:42:43 +0900 Subject: [PATCH 278/470] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=82=AD=EC=A0=9C=20API=20=EC=B6=94=EA=B0=80=20#75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/staff/SessionStaffController.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index 1efb924..8673fe5 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt @@ -95,4 +95,15 @@ class SessionStaffController( return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.getSessionDetailPage(detailId))) } + @DeleteMapping("/{id}") + @Operation( + summary = "세션 삭제", + description = "세션 ID로 세션과 상세, 연결된 모든 이미지를 삭제합니다. (S3에 저장된 이미지도 즉시 삭제)" + ) + fun deleteSession( + @PathVariable id: Long + ): ResponseEntity> { + sessionFacade.deleteSession(id) + return ResponseEntity.ok(SuccessResponse.ok(true)) + } } From 89bc108477ccdfe17d0afc57eefd4dda8574ae31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 1 Nov 2025 21:45:21 +0900 Subject: [PATCH 279/470] =?UTF-8?q?chore:=20swagger=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20summary=20=EC=88=98=EC=A0=95=20#75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/controller/staff/SessionStaffController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 8673fe5..dd1c32e 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt @@ -97,7 +97,7 @@ class SessionStaffController( @DeleteMapping("/{id}") @Operation( - summary = "세션 삭제", + summary = "세션 삭제 [TEMP]", description = "세션 ID로 세션과 상세, 연결된 모든 이미지를 삭제합니다. (S3에 저장된 이미지도 즉시 삭제)" ) fun deleteSession( From 37b9a88b712de1bcb81d01f21df7714df2de5d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 3 Nov 2025 15:18:52 +0900 Subject: [PATCH 280/470] =?UTF-8?q?refactor=20:=20=EB=B6=88=EC=B0=B8?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EC=84=9C=20=EC=84=B8=EC=85=98=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=EC=88=9C=EC=9C=BC=EB=A1=9C=20=EB=B0=98=ED=99=98=20#77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/session/repository/SessionRepository.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index 5ea1950..8181ce6 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -33,6 +33,7 @@ interface SessionRepository : CrudRepository { SELECT s FROM Session s WHERE s.startDate >= :now AND s.category <> :restCategory + ORDER BY s.startDate ASC """ ) fun findUpcomingSessions( From 97d0038a1bf21da960b58e029403e4e22c536f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 3 Nov 2025 15:49:42 +0900 Subject: [PATCH 281/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EC=97=90=20=EA=B3=B5=ED=9C=B4=EC=9D=BC=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/session/Session.kt | 2 ++ .../kotlin/onku/backend/domain/session/enums/SessionCategory.kt | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/session/Session.kt b/src/main/kotlin/onku/backend/domain/session/Session.kt index 56776f3..87f87da 100644 --- a/src/main/kotlin/onku/backend/domain/session/Session.kt +++ b/src/main/kotlin/onku/backend/domain/session/Session.kt @@ -37,4 +37,6 @@ class Session( var attendanceFinalized: Boolean = false, var attendanceFinalizedAt: LocalDateTime? = null, + @Column(name = "is_holiday") + var isHoliday : Boolean = false ) : BaseEntity() diff --git a/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt index 7e7af03..17b2e5a 100644 --- a/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt +++ b/src/main/kotlin/onku/backend/domain/session/enums/SessionCategory.kt @@ -10,6 +10,5 @@ enum class SessionCategory { @Schema(description = "기업 프로젝트") CORPORATE_PROJECT, @Schema(description = "밋업 프로젝트") MEETUP_PROJECT, @Schema(description = "네트워킹") NETWORKING, - @Schema(description = "공휴일 특별 세션 (가산점 1점)") HOLIDAY, @Schema(description = "휴회 세션") REST, } \ No newline at end of file From 5b665f79789a19ce8634a7fdc169d6eaaaf30443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 3 Nov 2025 16:03:37 +0900 Subject: [PATCH 282/470] =?UTF-8?q?refactor=20:=20=EA=B3=B5=ED=9C=B4?= =?UTF-8?q?=EC=9D=BC=20=EC=84=B8=EC=85=98=20=EC=97=AC=EB=B6=80=EB=8F=84=20?= =?UTF-8?q?dto=EC=97=90=20=EC=B6=94=EA=B0=80=20#77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/dto/response/GetSessionNoticeResponse.kt | 4 +++- .../backend/domain/session/dto/response/SessionCardInfo.kt | 4 +++- .../domain/session/dto/response/ThisWeekSessionInfo.kt | 4 +++- .../onku/backend/domain/session/facade/SessionFacade.kt | 3 ++- .../backend/domain/session/repository/SessionRepository.kt | 3 ++- .../onku/backend/domain/session/service/SessionService.kt | 6 ++++-- 6 files changed, 17 insertions(+), 7 deletions(-) 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 index dfa2805..13f08f9 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/response/GetSessionNoticeResponse.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/GetSessionNoticeResponse.kt @@ -23,5 +23,7 @@ data class GetSessionNoticeResponse( @Schema(description = "공지내용 데이터", example = "세션합니당") val content : String?, @Schema(description = "세션 관련 이미지", example = "객체임") - val images : List + val images : List, + @Schema(description = "공휴일 세션 여부", example = "true") + val isHoliday : 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 index cf3e179..90e5646 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/response/SessionCardInfo.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/SessionCardInfo.kt @@ -12,5 +12,7 @@ data class SessionCardInfo( @Schema(description = "세션 제목", example = "아이디어 발제 및 커피챗") val title : String?, @Schema(description = "세션 시작 일자", example = "2025-09-27") - val startDate : LocalDate? + val startDate : LocalDate?, + @Schema(description = "공휴일 세션 여부", example = "true") + val isHoliday : Boolean?, ) 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 index 0a86e6b..e51777e 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/response/ThisWeekSessionInfo.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/ThisWeekSessionInfo.kt @@ -18,5 +18,7 @@ data class ThisWeekSessionInfo( @Schema(description = "시작 시각", example = "13:00") val startTime : LocalTime?, @Schema(description = "종료 시각", example = "16:00") - val endTime : LocalTime? + val endTime : LocalTime?, + @Schema(description = "공휴일 세션 여부", example = "true") + val isHoliday : Boolean?, ) diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index 71e3449..5d1c32a 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -144,7 +144,8 @@ class SessionFacade( startTime = detail.startTime, endTime = detail.endTime, content = detail.content, - images = imageDtos + images = imageDtos, + isHoliday = session.isHoliday, ) } } \ 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 index 8181ce6..9f096d0 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -101,7 +101,8 @@ interface SessionRepository : CrudRepository { sd.place as place, s.startDate as startDate, sd.startTime as startTime, - sd.endTime as endTime + sd.endTime as endTime, + s.isHoliday as isHoliday FROM Session s LEFT JOIN s.sessionDetail sd WHERE s.startDate BETWEEN :start AND :end diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index 4249af0..827bfb1 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -89,7 +89,8 @@ class SessionService( place = it.place, startDate = it.startDate, startTime = it.startTime, - endTime = it.endTime + endTime = it.endTime, + isHoliday = it.isHoliday, ) } } @@ -101,7 +102,8 @@ class SessionService( sessionId = session.id!!, sessionCategory = session.category, title = session.title, - startDate = session.startDate + startDate = session.startDate, + isHoliday = session.isHoliday, ) } } From 4859b8cf7211e96c68723e8362699d4296a7403e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 3 Nov 2025 16:05:09 +0900 Subject: [PATCH 283/470] =?UTF-8?q?refactor=20:=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/session/controller/user/SessionController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4775a4b..79b10c2 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt @@ -39,7 +39,7 @@ class SessionController( return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.showThisWeekSessionInfo())) } - @GetMapping("/after/today") + @GetMapping("") @Operation( summary = "전체 세션 정보 조회", description = "전체 세션 정보를 시간순으로 조회합니다." From 2a5f135af49881bf32d5c65c141924f0b2c51a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 4 Nov 2025 00:19:20 +0900 Subject: [PATCH 284/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EA=B3=B5=ED=9C=B4=EC=9D=BC=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=97=AC=EB=B6=80=EB=8F=84=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EB=B0=9B=EA=B8=B0=20#79?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/session/dto/request/SessionSaveRequest.kt | 3 ++- .../onku/backend/domain/session/service/SessionService.kt | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index ab34daa..be697a6 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/request/SessionSaveRequest.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/request/SessionSaveRequest.kt @@ -9,5 +9,6 @@ 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 category: SessionCategory, + @field:NotNull val isHoliday: Boolean, ) \ 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 index cf4781a..2b878cd 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -1,7 +1,5 @@ package onku.backend.domain.session.service -import jakarta.persistence.EntityManager -import jakarta.persistence.PersistenceContext import onku.backend.domain.absence.repository.AbsenceReportRepository import onku.backend.domain.attendance.repository.AttendanceRepository import onku.backend.domain.session.validator.SessionValidator @@ -69,7 +67,8 @@ class SessionService( startDate = r.sessionDate, category = r.category, week = r.week, - sessionDetail = null + sessionDetail = null, + isHoliday = r.isHoliday ) } sessionRepository.saveAll(sessions) From 82dd00cc2b0a5e12aa16fd8167d1965bd5fdf3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 4 Nov 2025 16:04:59 +0900 Subject: [PATCH 285/470] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=EC=9D=98=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EC=83=81=ED=83=9C=EB=A5=BC=20enum?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/global/auth/enums/KakaoEnv.kt | 3 +++ .../kotlin/onku/backend/global/config/KakaoConfig.kt | 8 ++++++++ .../kotlin/onku/backend/global/config/KakaoProps.kt | 11 +++++++++++ 3 files changed, 22 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/auth/enums/KakaoEnv.kt create mode 100644 src/main/kotlin/onku/backend/global/config/KakaoConfig.kt create mode 100644 src/main/kotlin/onku/backend/global/config/KakaoProps.kt 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/config/KakaoConfig.kt b/src/main/kotlin/onku/backend/global/config/KakaoConfig.kt new file mode 100644 index 0000000..a0139f7 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/config/KakaoConfig.kt @@ -0,0 +1,8 @@ +package onku.backend.global.config + +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties(KakaoProps::class) +class KakaoConfig \ 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 From faa55146cfef0e8989045a29d3a5fba3f26cfde6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 4 Nov 2025 16:05:38 +0900 Subject: [PATCH 286/470] =?UTF-8?q?feat:=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=EC=97=90=EC=84=9C=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EC=83=81=ED=83=9C=EB=A5=BC=20request?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=EB=8B=AC=ED=95=98=EB=8F=84=EB=A1=9D=20dto?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/global/auth/dto/KakaoLoginRequest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginRequest.kt b/src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginRequest.kt index f4e287f..a006276 100644 --- a/src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginRequest.kt +++ b/src/main/kotlin/onku/backend/global/auth/dto/KakaoLoginRequest.kt @@ -1,5 +1,8 @@ package onku.backend.global.auth.dto +import onku.backend.global.auth.enums.KakaoEnv + data class KakaoLoginRequest( - val code: String + val code: String, + val env: KakaoEnv ) \ No newline at end of file From e89a46864566a12e2ba16af3b9c1253ca460b08a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 4 Nov 2025 16:08:29 +0900 Subject: [PATCH 287/470] =?UTF-8?q?refactor:=20YAML=EC=9D=98=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20redirect-uri=20=EC=A3=BC=EC=9E=85=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9D=84=20Map=20=EA=B8=B0=EB=B0=98=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=ED=99=98=EA=B2=BD(LOCAL/DEV)=20redirect-map=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=9C=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EC=A0=81=EC=9A=A9=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/service/AuthService.kt | 2 ++ .../global/auth/service/AuthServiceImpl.kt | 33 ++++++++++++++++--- .../global/auth/service/KakaoService.kt | 15 ++++----- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt index 858efb0..98aa884 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt @@ -1,5 +1,6 @@ package onku.backend.global.auth.service +import onku.backend.domain.member.Member import onku.backend.global.auth.dto.AuthLoginResult import onku.backend.global.auth.dto.KakaoLoginRequest import onku.backend.global.response.SuccessResponse @@ -9,5 +10,6 @@ interface AuthService { fun kakaoLogin(dto: KakaoLoginRequest): 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 index 3702fde..949d04b 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -1,5 +1,7 @@ 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 @@ -7,6 +9,7 @@ import onku.backend.global.auth.AuthErrorCode 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.KakaoProps import onku.backend.global.exception.CustomException import onku.backend.global.redis.cache.RefreshTokenCache import onku.backend.global.response.SuccessResponse @@ -27,11 +30,19 @@ class AuthServiceImpl( private val refreshTokenCacheUtil: RefreshTokenCache, @Value("\${jwt.refresh-ttl}") private val refreshTtl: Duration, @Value("\${jwt.onboarding-ttl}") private val onboardingTtl: Duration, + private val kakaoProps: KakaoProps, ) : AuthService { @Transactional override fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity> { - val token = kakaoService.getAccessToken(dto.code) + 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 @@ -155,12 +166,24 @@ class AuthServiceImpl( @Transactional override fun logout(refreshToken: String): ResponseEntity> { - val email = runCatching { jwtUtil.getEmail(refreshToken) }.getOrNull() - if (email != null) { - refreshTokenCacheUtil.deleteRefreshToken(email) - } + deleteRefreshTokenBy(refreshToken) return ResponseEntity .status(HttpStatus.OK) .body(SuccessResponse.ok("로그아웃 되었습니다.")) } + + @Transactional + override fun withdraw(member: Member, refreshToken: String): ResponseEntity> { + kakaoService.adminUnlink(member.socialId, 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 index 200adee..f1b7b98 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/KakaoService.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/KakaoService.kt @@ -4,7 +4,6 @@ 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.beans.factory.annotation.Value import org.springframework.http.ResponseEntity import org.springframework.stereotype.Service import org.springframework.util.LinkedMultiValueMap @@ -12,14 +11,14 @@ import org.springframework.util.MultiValueMap import org.springframework.web.client.RestClient @Service -class KakaoService( - @Value("\${oauth.kakao.client-id}") private val clientId: String, - @Value("\${oauth.kakao.redirect-uri}") private val redirectUri: String, - @Value("\${oauth.kakao.admin-key}") private val adminKey: String -) { +class KakaoService { private val client = RestClient.create() - fun getAccessToken(code: String): KakaoOAuthTokenResponse { + fun getAccessToken( + code: String, + redirectUri: String, + clientId: String + ): KakaoOAuthTokenResponse { val params: MultiValueMap = LinkedMultiValueMap().apply { add("grant_type", "authorization_code") add("client_id", clientId) @@ -55,7 +54,7 @@ class KakaoService( } } - fun adminUnlink(userId: Long) { + fun adminUnlink(userId: Long, adminKey: String) { val form: MultiValueMap = LinkedMultiValueMap().apply { add("target_id_type", "user_id") add("target_id", userId.toString()) From 3d8b008c45454f7b8f1331cd0b2445d4c526c9ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 4 Nov 2025 16:09:59 +0900 Subject: [PATCH 288/470] =?UTF-8?q?refactor:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EC=9C=84=EC=9E=84=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=8B=A8=EC=88=9C=ED=99=94=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/auth/controller/AuthController.kt | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt index 6d415b1..5db91fd 100644 --- a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt +++ b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt @@ -47,13 +47,6 @@ class AuthController( fun withdraw( @CurrentMember member: Member, @RequestHeader("X-Refresh-Token") refreshToken: String - ): ResponseEntity> { - val kakaoUserId = member.socialId - kakaoService.adminUnlink(kakaoUserId) - authService.logout(refreshToken) - val memberId = member.id ?: throw IllegalStateException("회원 ID가 없습니다.") - memberService.deleteMemberById(memberId) - - return ResponseEntity.ok(SuccessResponse.ok("회원 탈퇴가 완료되었습니다.")) - } + ): ResponseEntity> = + authService.withdraw(member, refreshToken) } \ No newline at end of file From 22429ee4e29086ccd166d6f90163c0d019e7f58c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 4 Nov 2025 16:10:17 +0900 Subject: [PATCH 289/470] =?UTF-8?q?feat:=20=EC=B6=94=EA=B0=80=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20auth=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=98=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt b/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt index 05b43f6..c2e15f4 100644 --- a/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt @@ -15,4 +15,6 @@ enum class AuthErrorCode( 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), + KAKAO_USER_ID_MISSING("AUTH400", "카카오 사용자 ID가 없습니다.", HttpStatus.BAD_REQUEST) } From 9eabea5b1d8a9fd5a86fb4990628dc018b928ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 4 Nov 2025 18:19:08 +0900 Subject: [PATCH 290/470] =?UTF-8?q?refactor:=20=EB=B3=80=EC=88=98=EB=AA=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/service/AdminPointCommandService.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt index c9bf6de..3cad1aa 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt @@ -32,15 +32,15 @@ class AdminPointCommandService( val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) val before = rec.studyPoints ?: 0 val after = studyPoints - val delta = after - before - if (delta != 0) { + 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 = delta + points = diff ) ) } @@ -54,15 +54,15 @@ class AdminPointCommandService( val rec = manualPointRecordRepository.findByMemberId(memberId) ?: newManualRecord(memberId) val before = rec.kupportersPoints ?: 0 val after = kupportersPoints - val delta = after - before - if (delta != 0) { + 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 = delta + points = diff ) ) } @@ -107,14 +107,14 @@ class AdminPointCommandService( val now = LocalDateTime.now(clock) val newValue = !member.isStaff - val delta = if (newValue) ManualPointType.STAFF.points else -ManualPointType.STAFF.points + val diff = if (newValue) ManualPointType.STAFF.points else -ManualPointType.STAFF.points memberPointHistoryRepository.save( MemberPointHistory.ofManual( member = member, manualType = ManualPointType.STAFF, occurredAt = now, - points = delta + points = diff ) ) member.isStaff = newValue @@ -144,13 +144,13 @@ class AdminPointCommandService( val newApproved = !target.approval target.updateApproval(newApproved) - val delta = if (newApproved) ManualPointType.KUPICK.points else -ManualPointType.KUPICK.points + val diff = if (newApproved) ManualPointType.KUPICK.points else -ManualPointType.KUPICK.points memberPointHistoryRepository.save( MemberPointHistory.ofManual( member = member, manualType = ManualPointType.KUPICK, occurredAt = now, - points = delta + points = diff ) ) From 82bc746e84eb70f3b8e48f6c18bfb8d7f3938eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 4 Nov 2025 18:27:41 +0900 Subject: [PATCH 291/470] =?UTF-8?q?refactor:=201=E2=86=920=20based=20pagin?= =?UTF-8?q?g=EC=9C=BC=EB=A1=9C=20=EB=B3=80=ED=99=98=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/point/service/MemberPointHistoryService.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt index 2ff5404..147e65d 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt @@ -19,8 +19,7 @@ class MemberPointHistoryService( ) { @Transactional(readOnly = true) - fun getHistory(member: Member, page1Based: Int, size: Int): MemberPointHistoryResponse { - val safePage = max(0, page1Based - 1) + fun getHistory(member: Member, safePage: Int, size: Int): MemberPointHistoryResponse { val pageable = PageRequest.of(safePage, size) val profile = memberProfileRepository.findById(member.id!!) From b15c558e723a727f83b25259947a7c09f683d555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 4 Nov 2025 21:47:57 +0900 Subject: [PATCH 292/470] =?UTF-8?q?feat=20:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4(=EC=A0=9C=EB=AA=A9,=20=EB=82=B4=EC=9A=A9)=20?= =?UTF-8?q?=EC=83=81=EC=88=98=ED=99=94=20#84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/global/alarm/AlarmMessage.kt | 10 ++++++++++ .../kotlin/onku/backend/global/alarm/AlarmTitle.kt | 5 +++++ 2 files changed, 15 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/alarm/AlarmMessage.kt create mode 100644 src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt 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..41c80aa --- /dev/null +++ b/src/main/kotlin/onku/backend/global/alarm/AlarmMessage.kt @@ -0,0 +1,10 @@ +package onku.backend.global.alarm + +object AlarmMessage { + fun kupick(month : Int, status : Boolean): String { + if(status) { + return "신청하신 ${month}월 큐픽이 승인되었어요" + } + return "신청하신 ${month}월 큐픽이 반려되었어요" + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt b/src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt new file mode 100644 index 0000000..b5049d3 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt @@ -0,0 +1,5 @@ +package onku.backend.global.alarm + +object AlarmTitle { + const val KUPICK = "큐픽 관련 알림입니다." +} \ No newline at end of file From 2b8d43bb6f2067746da7aa527b99448f05a95b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 4 Nov 2025 21:48:16 +0900 Subject: [PATCH 293/470] =?UTF-8?q?feat=20:=20=ED=81=90=ED=94=BD=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8/=EB=B0=98=EB=A0=A4=20=EC=8B=9C=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EC=A0=84=EC=86=A1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/kupick/dto/KupickFcmInfo.kt | 8 ++++++++ .../backend/domain/kupick/facade/KupickFacade.kt | 15 +++++++++++++++ .../domain/kupick/repository/KupickRepository.kt | 9 +++++++++ .../domain/kupick/service/KupickService.kt | 6 ++++++ 4 files changed, 38 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/kupick/dto/KupickFcmInfo.kt diff --git a/src/main/kotlin/onku/backend/domain/kupick/dto/KupickFcmInfo.kt b/src/main/kotlin/onku/backend/domain/kupick/dto/KupickFcmInfo.kt new file mode 100644 index 0000000..509eed9 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/kupick/dto/KupickFcmInfo.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.kupick.dto + +import java.time.LocalDateTime + +data class KupickFcmInfo( + val fcmToken: String?, + val submitDate: LocalDateTime? +) diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index def0c65..d1f7373 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -5,6 +5,9 @@ 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.AlarmMessage +import onku.backend.global.alarm.AlarmTitle +import onku.backend.global.alarm.FCMService import onku.backend.global.s3.dto.GetUpdateAndDeleteUrlDto import onku.backend.global.s3.enums.FolderName import onku.backend.global.s3.enums.UploadOption @@ -15,6 +18,7 @@ import org.springframework.stereotype.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) @@ -84,6 +88,17 @@ class KupickFacade( fun decideApproval(kupickApprovalRequest: KupickApprovalRequest): Boolean { kupickService.decideApproval(kupickApprovalRequest.kupickId, kupickApprovalRequest.approval) + val info = kupickService.findFcmInfo(kupickApprovalRequest.kupickId) + val fcmToken = info?.fcmToken + val submitMonth = info?.submitDate?.monthValue + fcmToken?.let { + fcmService.sendMessageTo( + targetToken = it, + title = AlarmTitle.KUPICK, + 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 index 456f55f..7a2d10e 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt @@ -1,6 +1,7 @@ package onku.backend.domain.kupick.repository import onku.backend.domain.kupick.Kupick +import onku.backend.domain.kupick.dto.KupickFcmInfo import onku.backend.domain.kupick.repository.projection.KupickUrls import onku.backend.domain.kupick.repository.projection.KupickWithProfile import onku.backend.domain.member.Member @@ -71,4 +72,12 @@ interface KupickRepository : JpaRepository { start: LocalDateTime, end: LocalDateTime ): List> + + @Query(""" + SELECT new onku.backend.domain.kupick.dto.KupickFcmInfo(m.fcmToken, k.submitDate) + FROM Kupick k + JOIN k.member m + WHERE k.id = :kupickId +""") + fun findFcmInfoByKupickId(@Param("kupickId") kupickId: Long): KupickFcmInfo? } \ 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 index f1e5817..ae87b84 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -2,6 +2,7 @@ package onku.backend.domain.kupick.service import onku.backend.domain.kupick.Kupick import onku.backend.domain.kupick.KupickErrorCode +import onku.backend.domain.kupick.dto.KupickFcmInfo import onku.backend.domain.kupick.repository.KupickRepository import onku.backend.domain.kupick.repository.projection.KupickUrls import onku.backend.domain.kupick.repository.projection.KupickWithProfile @@ -80,4 +81,9 @@ class KupickService( ?: throw CustomException(KupickErrorCode.KUPICK_NOT_FOUND) kupick.updateApproval(approval) } + + @Transactional(readOnly = true) + fun findFcmInfo(kupickId: Long) : KupickFcmInfo? { + return kupickRepository.findFcmInfoByKupickId(kupickId) + } } \ No newline at end of file From df0f770cf02f076b09704abc23321b1672a580a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 17:25:31 +0900 Subject: [PATCH 294/470] =?UTF-8?q?feat:=20ABSENT=5FWITH=5FCAUSE(=EA=B8=B0?= =?UTF-8?q?=ED=83=80=20=EC=82=AC=EC=9C=A0,=20-1=EC=A0=90)=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=B6=94=EA=B0=80=20#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/attendance/enums/AttendancePointType.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendancePointType.kt b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendancePointType.kt index 62cc661..c217cb3 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/enums/AttendancePointType.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/enums/AttendancePointType.kt @@ -22,6 +22,9 @@ enum class AttendancePointType( @Schema(description = "결석(사유서 제출): -2점") ABSENT_WITH_DOC(-2), + @Schema(description = "기타 사유(사유서 제출): -1점") + ABSENT_WITH_CAUSE(-1), + @Schema(description = "지각: -1점") LATE(-1), From c7e64be13b93f47018dd19894deaaef77af620fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 17:54:46 +0900 Subject: [PATCH 295/470] =?UTF-8?q?chore:=20=EC=A4=84=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/session/Session.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/onku/backend/domain/session/Session.kt b/src/main/kotlin/onku/backend/domain/session/Session.kt index 87f87da..a8816bc 100644 --- a/src/main/kotlin/onku/backend/domain/session/Session.kt +++ b/src/main/kotlin/onku/backend/domain/session/Session.kt @@ -37,6 +37,7 @@ class Session( var attendanceFinalized: Boolean = false, var attendanceFinalizedAt: LocalDateTime? = null, + @Column(name = "is_holiday") var isHoliday : Boolean = false ) : BaseEntity() From fffbd66d8112ac22d493fcc6ae020f3a1f3663fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 17:56:19 +0900 Subject: [PATCH 296/470] =?UTF-8?q?feat:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=EC=A0=9C=EC=B6=9C=EC=8B=9C=EC=9D=98=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=99=80=20=EC=9A=B4=EC=98=81=EC=A7=84=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=EC=8B=9C=EC=9D=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EB=B6=84=EB=A6=AC=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/absence/AbsenceReport.kt | 20 ++++++++++++------- .../absence/enums/AbsenceApprovedType.kt | 5 +++++ .../{AbsenceType.kt => AbsenceSubmitType.kt} | 2 +- 3 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt rename src/main/kotlin/onku/backend/domain/absence/enums/{AbsenceType.kt => AbsenceSubmitType.kt} (70%) diff --git a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt index 590d0b8..06b5b84 100644 --- a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt +++ b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt @@ -3,7 +3,8 @@ 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.AbsenceType +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 @@ -25,7 +26,7 @@ class AbsenceReport( @Column(name = "absence_report_id") val id: Long? = null, - @OneToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "session_id") var session : Session, @@ -36,9 +37,13 @@ class AbsenceReport( @Column(name = "url") var url : String, - @Column(name = "status") - @Enumerated(EnumType.STRING) - var status : AbsenceType, + @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, @@ -64,7 +69,8 @@ class AbsenceReport( member = member, session = session, url = fileKey, - status = submitAbsenceReportRequest.absenceType, + submitType = submitAbsenceReportRequest.submitType, + approvedType = AbsenceApprovedType.ABSENT, reason = submitAbsenceReportRequest.reason, approval = AbsenceReportApproval.SUBMIT, leaveDateTime = submitAbsenceReportRequest.leaveDateTime, @@ -81,7 +87,7 @@ class AbsenceReport( this.session = session this.reason = submitAbsenceReportRequest.reason this.url = fileKey - this.status = submitAbsenceReportRequest.absenceType + this.submitType = submitAbsenceReportRequest.submitType this.updatedAt = LocalDateTime.now() this.leaveDateTime = submitAbsenceReportRequest.leaveDateTime this.lateDateTime = submitAbsenceReportRequest.lateDateTime 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..192b764 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt @@ -0,0 +1,5 @@ +package onku.backend.domain.absence.enums + +enum class AbsenceApprovedType { + EXCUSED, ABSENT, LATE, ABSENT_WITH_DOC, ABSENT_WITH_CAUSE, EARLY_LEAVE +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceType.kt b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceSubmitType.kt similarity index 70% rename from src/main/kotlin/onku/backend/domain/absence/enums/AbsenceType.kt rename to src/main/kotlin/onku/backend/domain/absence/enums/AbsenceSubmitType.kt index b55e77d..dda0a8c 100644 --- a/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceType.kt +++ b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceSubmitType.kt @@ -1,5 +1,5 @@ package onku.backend.domain.absence.enums -enum class AbsenceType { +enum class AbsenceSubmitType { ABSENT, LATE, EARLY_LEAVE } \ No newline at end of file From 39c9883be031fe813c8224515f3ff3457335db5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 17:59:56 +0900 Subject: [PATCH 297/470] =?UTF-8?q?chore:=20submitType,=20approvedType=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EB=B6=84=EB=A6=AC=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../absence/dto/request/SubmitAbsenceReportRequest.kt | 4 ++-- .../absence/dto/response/GetMyAbsenceReportResponse.kt | 4 ++-- .../repository/projection/GetMyAbsenceReportView.kt | 4 ++-- .../backend/domain/absence/service/AbsenceService.kt | 6 +++--- .../domain/absence/validator/AbsenceReportValidator.kt | 10 +++++----- 5 files changed, 14 insertions(+), 14 deletions(-) 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 index 808ec2f..6d6bb1b 100644 --- a/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt +++ b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt @@ -2,13 +2,13 @@ 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.AbsenceType +import onku.backend.domain.absence.enums.AbsenceSubmitType import java.time.LocalDateTime @ValidAbsenceReport data class SubmitAbsenceReportRequest( val absenceReportId : Long?, @field:NotNull val sessionId : Long, - val absenceType : AbsenceType, + val submitType : AbsenceSubmitType, val reason : String, val fileName : String, val lateDateTime : LocalDateTime?, 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 index b12e09d..0e0a269 100644 --- a/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMyAbsenceReportResponse.kt +++ b/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMyAbsenceReportResponse.kt @@ -1,13 +1,13 @@ package onku.backend.domain.absence.dto.response import onku.backend.domain.absence.enums.AbsenceReportApproval -import onku.backend.domain.absence.enums.AbsenceType +import onku.backend.domain.absence.enums.AbsenceSubmitType import java.time.LocalDate import java.time.LocalDateTime data class GetMyAbsenceReportResponse( val absenceReportId : Long, - val absenceType : AbsenceType, + val absenceType : AbsenceSubmitType, val absenceReportApproval : AbsenceReportApproval, val submitDateTime : LocalDateTime, val sessionTitle : String, 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 index a16aa9b..9b0ac69 100644 --- a/src/main/kotlin/onku/backend/domain/absence/repository/projection/GetMyAbsenceReportView.kt +++ b/src/main/kotlin/onku/backend/domain/absence/repository/projection/GetMyAbsenceReportView.kt @@ -1,13 +1,13 @@ package onku.backend.domain.absence.repository.projection import onku.backend.domain.absence.enums.AbsenceReportApproval -import onku.backend.domain.absence.enums.AbsenceType +import onku.backend.domain.absence.enums.AbsenceSubmitType import java.time.LocalDate import java.time.LocalDateTime interface GetMyAbsenceReportView { fun getAbsenceReportId(): Long - fun getAbsenceType(): AbsenceType + fun getAbsenceSubmitType(): AbsenceSubmitType fun getAbsenceReportApproval(): AbsenceReportApproval fun getSubmitDateTime(): LocalDateTime fun getSessionTitle(): String diff --git a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt index 76e1e49..ed4cd19 100644 --- a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -27,8 +27,8 @@ class AbsenceService( AbsenceReport.createAbsenceReport( member = member, session = session, - submitAbsenceReportRequest, - fileKey + submitAbsenceReportRequest = submitAbsenceReportRequest, + fileKey = fileKey ) } absenceReportRepository.save(report) @@ -40,7 +40,7 @@ class AbsenceService( return absenceReports.map { a -> GetMyAbsenceReportResponse( absenceReportId = a.getAbsenceReportId(), - absenceType = a.getAbsenceType(), + absenceType = a.getAbsenceSubmitType(), absenceReportApproval = a.getAbsenceReportApproval(), submitDateTime = a.getSubmitDateTime(), sessionTitle = a.getSessionTitle(), diff --git a/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceReportValidator.kt b/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceReportValidator.kt index e2512dd..adfbc0e 100644 --- a/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceReportValidator.kt +++ b/src/main/kotlin/onku/backend/domain/absence/validator/AbsenceReportValidator.kt @@ -4,7 +4,7 @@ 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.AbsenceType +import onku.backend.domain.absence.enums.AbsenceSubmitType class AbsenceReportValidator : ConstraintValidator { override fun isValid( @@ -13,7 +13,7 @@ class AbsenceReportValidator : ConstraintValidator { + AbsenceSubmitType.LATE -> { if (leave != null) { context.buildConstraintViolationWithTemplate("지각일 경우 leaveDateTime은 비워야 합니다.") .addPropertyNode("leaveDateTime").addConstraintViolation() false } else true } - AbsenceType.EARLY_LEAVE -> { + AbsenceSubmitType.EARLY_LEAVE -> { if (late != null) { context.buildConstraintViolationWithTemplate("조퇴일 경우 lateDateTime은 비워야 합니다.") .addPropertyNode("lateDateTime").addConstraintViolation() false } else true } - AbsenceType.ABSENT -> { + AbsenceSubmitType.ABSENT -> { var valid = true if (late != null) { context.buildConstraintViolationWithTemplate("결석일 경우 lateDateTime은 비워야 합니다.") From 3feeeb27bde8d4026cf0a79c20e9b41c5cc8c3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 18:00:41 +0900 Subject: [PATCH 298/470] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=EB=AC=B8=20=EC=A0=9C=EA=B1=B0=20#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/point/service/MemberPointHistoryService.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt index 147e65d..1861eec 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt @@ -10,7 +10,6 @@ import onku.backend.global.exception.CustomException import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import kotlin.math.max @Service class MemberPointHistoryService( From d331b5030e91dff53628e5521e5426a341e8e2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 18:01:19 +0900 Subject: [PATCH 299/470] =?UTF-8?q?feat:=20=ED=95=A8=EC=88=98=EB=AA=85=20g?= =?UTF-8?q?etCnt=20=E2=86=92=20getCount=20=EB=A1=9C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/attendance/repository/AttendanceRepository.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt index 9d3d7a3..fb69165 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt @@ -1,6 +1,5 @@ package onku.backend.domain.attendance.repository -import jakarta.transaction.Transactional import onku.backend.domain.attendance.Attendance import onku.backend.domain.attendance.enums.AttendancePointType import org.springframework.data.jpa.repository.Modifying @@ -12,7 +11,7 @@ import java.time.LocalDateTime interface StatusCountProjection { fun getStatus(): AttendancePointType - fun getCnt(): Long + fun getCount(): Long } interface AttendanceRepository : CrudRepository { From 45c162a0072faee9ffa471e725884f38805ddb04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 18:05:03 +0900 Subject: [PATCH 300/470] =?UTF-8?q?feat:=20ABSENT=5FWITH=5FCAUSE=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/service/AttendanceService.kt | 15 ++++++++++----- .../backend/domain/point/MemberPointHistory.kt | 15 ++++++++------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index 12fed5f..a3d601e 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -73,14 +73,15 @@ class AttendanceService( rows.forEach { r -> when (r.getStatus()) { AttendancePointType.PRESENT, - AttendancePointType.PRESENT_HOLIDAY -> present += r.getCnt() + AttendancePointType.PRESENT_HOLIDAY -> present += r.getCount() - AttendancePointType.EARLY_LEAVE -> earlyLeave += r.getCnt() - AttendancePointType.LATE -> late += r.getCnt() + AttendancePointType.EARLY_LEAVE -> earlyLeave += r.getCount() + AttendancePointType.LATE -> late += r.getCount() AttendancePointType.EXCUSED, AttendancePointType.ABSENT, - AttendancePointType.ABSENT_WITH_DOC -> absent += r.getCnt() + AttendancePointType.ABSENT_WITH_DOC, + AttendancePointType.ABSENT_WITH_CAUSE -> absent += r.getCount() } } @@ -117,7 +118,11 @@ class AttendanceService( val state = when { now.isAfter(lateThreshold) -> AttendancePointType.ABSENT !now.isBefore(startDateTime) -> AttendancePointType.LATE - else -> AttendancePointType.PRESENT + else -> if (session.isHoliday) { + AttendancePointType.PRESENT_HOLIDAY + } else { + AttendancePointType.PRESENT + } } try { diff --git a/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt index 745b11c..058ff0b 100644 --- a/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt +++ b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt @@ -81,13 +81,14 @@ class MemberPointHistory( ) AttendancePointType.EXCUSED, AttendancePointType.ABSENT, - AttendancePointType.ABSENT_WITH_DOC -> MemberPointHistory( - member = member, - category = PointCategory.ATTENDANCE, - type = status.name, - points = status.points, - occurredAt = occurredAt, - week = week + AttendancePointType.ABSENT_WITH_DOC, + AttendancePointType.ABSENT_WITH_CAUSE -> MemberPointHistory( + member = member, + category = PointCategory.ATTENDANCE, + type = status.name, + points = status.points, + occurredAt = occurredAt, + week = week ) } } From e65e689158047f143aaecc5c544d89bb5267e8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 18:05:52 +0900 Subject: [PATCH 301/470] =?UTF-8?q?chore:=20submitType,=20approvedType=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EB=B6=84=EB=A6=AC=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/absence/repository/AbsenceReportRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt index 304a2b0..abd8b24 100644 --- a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -13,7 +13,7 @@ interface AbsenceReportRepository : JpaRepository { """ select ar.id as absenceReportId, - ar.status as absenceType, + ar.submitType as absenceType, ar.approval as absenceReportApproval, ar.createdAt as submitDateTime, s.title as sessionTitle, From de1f95db72a7e765f13ac72e21cf2d1c1543a323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 18:07:43 +0900 Subject: [PATCH 302/470] =?UTF-8?q?feat:=20AbsentReport=EC=9D=98=20approve?= =?UTF-8?q?dType=EA=B3=BC=20Attendance=EC=9D=98=20AttendancePointType?= =?UTF-8?q?=EC=9D=84=20=EB=A7=A4=ED=95=91=ED=95=98=EB=8A=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AbsenceReportToAttendancePointMapper.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/attendance/util/AbsenceReportToAttendancePointMapper.kt 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 From b034efb114638269f375576e47cfe6a1344ffcd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 18:08:15 +0900 Subject: [PATCH 303/470] =?UTF-8?q?feat:=20AbsenceReportToAttendancePointM?= =?UTF-8?q?apper=20=EC=A0=81=EC=9A=A9=20#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AttendanceFinalizeService.kt | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt index 4415520..df72bca 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceFinalizeService.kt @@ -2,11 +2,11 @@ package onku.backend.domain.attendance.service import jakarta.persistence.EntityManager import jakarta.persistence.PersistenceContext -import onku.backend.domain.absence.enums.AbsenceReportApproval 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 @@ -53,7 +53,11 @@ class AttendanceFinalizeService( .associateBy { it.member.id!! } missing.forEach { memberId -> - val status = mapApprovalToStatus(papers[memberId]?.approval) + val report = papers[memberId] + val status: AttendancePointType = + report?.let { AbsenceReportToAttendancePointMapper.map(it.approval, it.approvedType) } + ?: AttendancePointType.ABSENT + try { val inserted = attendanceRepository.insertOnly( sessionId = sessionId, @@ -74,20 +78,12 @@ class AttendanceFinalizeService( memberPointHistoryRepository.save(history) } } catch (_: DataIntegrityViolationException) { - // 멱등: 유니크 제약 등으로 이미 들어간 경우 무시 } } } markFinalized(session, now) } - private fun mapApprovalToStatus(approval: AbsenceReportApproval?): AttendancePointType = - when (approval) { - AbsenceReportApproval.APPROVED -> AttendancePointType.EXCUSED - AbsenceReportApproval.SUBMIT -> AttendancePointType.ABSENT_WITH_DOC - null -> AttendancePointType.ABSENT - } - private fun markFinalized(session: Session, now: LocalDateTime) { session.attendanceFinalized = true session.attendanceFinalizedAt = now From fd678cfc05416a1b8714ff6fb6a6918f7d08b384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 19:04:12 +0900 Subject: [PATCH 304/470] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84=20?= =?UTF-8?q?=EC=9B=94=EA=B0=84=20=EC=B6=9C=EC=84=9D=20=ED=98=84=ED=99=A9?= =?UTF-8?q?=EC=9D=84=20MemberPointHistory=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20#87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/point/service/AdminPointService.kt | 119 +++++++++++------- 1 file changed, 77 insertions(+), 42 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt index 903aacc..d370ffa 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt @@ -1,6 +1,7 @@ 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 @@ -9,24 +10,29 @@ 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.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.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.* +import java.time.Clock +import java.time.LocalDate +import java.time.LocalDateTime import kotlin.math.max -import onku.backend.global.time.TimeRangeUtil @Service class AdminPointService( private val memberProfileRepository: MemberProfileRepository, - private val attendanceRepository: AttendanceRepository, private val kupickRepository: KupickRepository, private val manualPointRecordRepository: ManualPointRepository, private val sessionRepository: SessionRepository, + private val memberPointHistoryRepository: MemberPointHistoryRepository, + private val attendanceRepository: AttendanceRepository, private val clock: Clock ) { @@ -50,18 +56,30 @@ class AdminPointService( val startOfAug: LocalDateTime = augRange.startOfMonth val endExclusive: LocalDateTime = decRange.startOfNextMonth - // 출석 레코드 → 월별 포인트 합산 - val monthlyAttendanceTotals: MutableMap> = mutableMapOf() - attendanceRepository.findByMemberIdInAndAttendanceTimeBetween(memberIds, startOfAug, endExclusive) - .forEach { attendance -> - val month = attendance.attendanceTime.month.value - val mapForMember = monthlyAttendanceTotals.getOrPut(attendance.memberId) { initMonthScoreMap() } - mapForMember[month] = (mapForMember[month] ?: 0) + attendance.status.points // 동일 월 포인트 합산 + // 출석 레코드 → 월별 포인트 합산 (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() + } } - // 큐픽 참여 여부를 월별로 표시 (기본 false → 참여 시 true) - val kupickParticipationByMember: MutableMap> = mutableMapOf() - memberIds.forEach { id -> kupickParticipationByMember[id] = initMonthParticipationMap() } + // 큐픽 참여 여부 + val kupickParticipationByMember = + memberIds.associateWith { initMonthParticipationMap() }.toMutableMap() + kupickRepository.findMemberMonthParticipation(memberIds, startOfAug, endExclusive) .forEach { row -> val memberId = (row[0] as Number).toLong() @@ -70,12 +88,9 @@ class AdminPointService( kupickParticipationByMember[memberId]!![month] = true } } - - // 스터디/큐포터즈/메모 조회 val manualPointsByMember = manualPointRecordRepository.findByMemberIdIn(memberIds) .associateBy { it.memberId!! } - // 페이지 단위 DTO 변환 val dtoPage = profilePage.map { profile -> val memberId = profile.memberId!! val monthTotals = monthlyAttendanceTotals[memberId] ?: initMonthScoreMap() @@ -135,13 +150,22 @@ class AdminPointService( ): MonthlyAttendancePageResponse { require(month in 8..12) { "month must be 8..12" } - // 조회 구간 설정 + // 조회 구간 val monthRange = TimeRangeUtil.monthRange(year, month, clock.zone) val start: LocalDateTime = monthRange.startOfMonth val end: LocalDateTime = monthRange.startOfNextMonth - // 세션 시작일 (중복 제거/오름차순) - val sessionDates: List = sessionRepository.findStartTimesBetween(start, end) + // 세션 시작일 + val startDate: LocalDate = start.toLocalDate() + val endDateInclusive: LocalDate = end.minusNanos(1).toLocalDate() + + val startParts = sessionRepository.findStartDateAndTimeBetweenDates(startDate, endDateInclusive) + + val sessionStartDateTimes: List = startParts + .map { LocalDateTime.of(it.getStartDate(), it.getStartTime()) } + .filter { dt -> !dt.isBefore(start) && dt.isBefore(end) } + + val sessionDates: List = sessionStartDateTimes .map { it.toLocalDate() } .distinct() .sorted() @@ -151,34 +175,47 @@ class AdminPointService( 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) - } + if (pageMemberIds.isEmpty()) throw CustomException(MemberErrorCode.PAGE_MEMBERS_NOT_FOUND) + + val historyRecords = memberPointHistoryRepository.findAttendanceByMemberIdsBetween( + memberIds = pageMemberIds, + category = PointCategory.ATTENDANCE, + start = start, + end = end + ) + + val attendanceList = attendanceRepository.findByMemberIdInAndAttendanceTimeBetween( + pageMemberIds, start, end + ) + val attendanceIdByMemberDate: Map, Long> = + attendanceList.associateBy( + { it.memberId to it.attendanceTime.toLocalDate() }, + { it.id!! } + ) - // 해당 페이지 멤버의 월간 출석 레코드 조회 data class Row( val memberId: Long, val date: LocalDate, val attendanceId: Long?, - val status: onku.backend.domain.attendance.enums.AttendancePointType?, - val point: Int? + val status: AttendancePointType, + val point: Int ) - val rows: List = attendanceRepository - .findByMemberIdInAndAttendanceTimeBetween(pageMemberIds, start, end) - .map { a -> - Row( - memberId = a.memberId, - date = a.attendanceTime.toLocalDate(), - attendanceId = a.id, - status = a.status, - point = a.status.points - ) - } + val rows: List = historyRecords.map { r -> + val memberId = r.member.id!! + val date = r.occurredAt.toLocalDate() + val attId = attendanceIdByMemberDate[memberId to date] + Row( + memberId = memberId, + date = date, + attendanceId = attId, + status = AttendancePointType.valueOf(r.type), + point = r.points + ) + } - val rowsByMember = rows.groupBy { it.memberId } // 멤버별로 레코드 그룹핑 + val rowsByMember = rows.groupBy { it.memberId } - // 멤버별 일자 정렬 + 세션일 기준 결측 레코드 채움 val memberDtos = memberPage.content.map { profile -> val memberId = profile.memberId!! val baseRecords = rowsByMember[memberId] @@ -194,20 +231,19 @@ class AdminPointService( ?.toMutableList() ?: mutableListOf() - // 세션일이 존재하지만 기록이 없는 날짜는 null 처리 if (sessionDates.isNotEmpty()) { val recordedDates = baseRecords.map { it.date }.toSet() sessionDates.filter { it !in recordedDates }.forEach { date -> baseRecords.add( AttendanceRecordDto( date = date, - attendanceId = null, + attendanceId = attendanceIdByMemberDate[memberId to date], status = null, point = null ) ) } - baseRecords.sortBy { it.date } // 날짜 오름차순 정렬 + baseRecords.sortBy { it.date } } MemberMonthlyAttendanceDto( @@ -217,7 +253,6 @@ class AdminPointService( ) } - // 멤버 페이지 순서를 유지하며 DTO 페이지 구성 val dtoPage = memberPage.map { p -> memberDtos.first { it.memberId == p.memberId } } From 04cb5addfb8a2fd6cee6f3a77e3c5ace34c12dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 19:09:58 +0900 Subject: [PATCH 305/470] =?UTF-8?q?fix:=20JPQL=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=C2=B7=EC=8B=9C=EA=B0=84=EC=9D=84=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20=EC=BB=AC=EB=9F=BC=EC=9C=BC=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=B4=20LocalDateTime=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=95=A9=EC=84=B1=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EC=97=AC=20ConversionFailedException(ArrayLi?= =?UTF-8?q?st=E2=86=92LocalDateTime=20=EB=B3=80=ED=99=98=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98)=20=ED=95=B4=EA=B2=B0=20#87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/repository/SessionRepository.kt | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index 42e9b3d..63e6604 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -12,7 +12,6 @@ import org.springframework.data.repository.query.Param import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime -import java.util.* interface SessionRepository : CrudRepository { @@ -58,16 +57,23 @@ interface SessionRepository : CrudRepository { """) fun findAll(pageable: Pageable): Page - @Query(""" - SELECT function('timestamp', sess.startDate, d.startTime) - FROM Session sess join sess.sessionDetail d - WHERE function('timestamp', sess.startDate, d.startTime) >= :start - AND function('timestamp', sess.startDate, d.startTime) < :end - """) - fun findStartTimesBetween( - @Param("start") start: LocalDateTime, - @Param("end") end: LocalDateTime - ): List + 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 9a5da1eb877544d775f54a49f0c57992b5e84fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 19:11:35 +0900 Subject: [PATCH 306/470] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84=20A?= =?UTF-8?q?PI=EC=97=90=EC=84=9C=EB=8F=84=20MemberPointHistory=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=9B=94=EA=B0=84=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EC=A7=80=EC=9B=90=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80=20#87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberPointHistoryRepository.kt | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt index 92fc7f3..abdeec0 100644 --- a/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt +++ b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt @@ -2,10 +2,13 @@ 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 { @@ -26,5 +29,47 @@ interface MemberPointHistoryRepository : JpaRepository WHERE r.member = :member """ ) - fun sumPointsForMember(member: Member): MemberPointSums -} + 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 +} \ No newline at end of file From cff2c5f41abfded85e9832ad48850972b12128d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 19:13:32 +0900 Subject: [PATCH 307/470] =?UTF-8?q?chore:=20AuthController=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/auth/controller/AuthController.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt index 5db91fd..d9e6cd6 100644 --- a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt +++ b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt @@ -3,12 +3,10 @@ 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.domain.member.service.MemberService import onku.backend.global.annotation.CurrentMember 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.auth.service.KakaoService import onku.backend.global.response.SuccessResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -18,8 +16,6 @@ import org.springframework.web.bind.annotation.* @Tag(name = "인증 API", description = "소셜 로그인 및 토큰 재발급") class AuthController( private val authService: AuthService, - private val kakaoService: KakaoService, - private val memberService: MemberService, ) { @PostMapping("/kakao") @Operation(summary = "카카오 로그인", description = "인가코드를 body로 받아 사용자를 식별합니다.") From be0c4bb7468b5a17f3604e0070d662f3217c0fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 19:28:05 +0900 Subject: [PATCH 308/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=88=98=EC=A0=95=20dto=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/dto/MemberRoleResponse.kt | 11 +++++++++++ .../domain/member/dto/UpdateRoleRequest.kt | 15 +++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/MemberRoleResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/UpdateRoleRequest.kt 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/UpdateRoleRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/UpdateRoleRequest.kt new file mode 100644 index 0000000..56e29b7 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/UpdateRoleRequest.kt @@ -0,0 +1,15 @@ +package onku.backend.domain.member.dto + +import io.swagger.v3.oas.annotations.media.Schema +import onku.backend.domain.member.enums.Role +import software.amazon.awssdk.annotations.NotNull + +data class UpdateRoleRequest( + @field:NotNull + @Schema(description = "권한을 변경할 사용자 ID", example = "1") + val memberId: Long?, + + @field:NotNull + @Schema(description = "변경할 권한", example = "STAFF") + val role: Role? +) \ No newline at end of file From 354d514b3b928bd9d70ab9701c977ed520274e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 19:28:23 +0900 Subject: [PATCH 309/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt b/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt index d5f4763..10f8072 100644 --- a/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt +++ b/src/main/kotlin/onku/backend/domain/member/MemberErrorCode.kt @@ -11,4 +11,5 @@ enum class MemberErrorCode( 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), } From fdc1bbea8a4e20066628618d66603c5d68e9ea94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 19:29:02 +0900 Subject: [PATCH 310/470] =?UTF-8?q?feat:=20=EA=B6=8C=ED=95=9C=EB=B3=84=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=EC=B2=98=EB=A6=AC=20=EC=97=86=EC=9D=B4=20QA?= =?UTF-8?q?=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EA=B6=8C=ED=95=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20#88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/MemberService.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt index 1519101..7eae73b 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -3,12 +3,15 @@ package onku.backend.domain.member.service import onku.backend.domain.member.Member import onku.backend.domain.member.MemberErrorCode import onku.backend.domain.member.dto.MemberApprovalResponse +import onku.backend.domain.member.dto.MemberRoleResponse +import onku.backend.domain.member.dto.UpdateRoleRequest import onku.backend.domain.member.enums.ApprovalStatus import onku.backend.domain.member.enums.Role import onku.backend.domain.member.enums.SocialType import onku.backend.domain.member.repository.MemberProfileRepository import onku.backend.domain.member.repository.MemberRepository import onku.backend.global.exception.CustomException +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -98,4 +101,22 @@ class MemberService( approval = saved.approval ) } + + @Transactional + fun updateRole(actor: Member, req: UpdateRoleRequest): MemberRoleResponse { + val targetMemberId = req.memberId ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + val newRole = req.role ?: throw CustomException(MemberErrorCode.INVALID_REQUEST) + + val target = memberRepository.findByIdOrNull(targetMemberId) + ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + + // TODO: actor.role에 따른 변경 허용 범위 검증 로직 복구/추가 + target.role = newRole + memberRepository.save(target) + + return MemberRoleResponse( + memberId = target.id!!, + role = target.role + ) + } } From 9a6cb49d5123614e2e3b0879af21b745fc8a8620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 19:29:19 +0900 Subject: [PATCH 311/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=88=98=EC=A0=95=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/controller/MemberController.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt index 0b930dd..27c74fa 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt @@ -76,4 +76,17 @@ class MemberController( val result = memberService.updateApproval(memberId, body.status) return ResponseEntity.ok(SuccessResponse.ok(result)) } + + @Operation( + summary = "[QA] 사용자 권한(role) 수정", + description = "요청 본문에 수정하고자 하는 memberId, role을 담아 권한을 수정합니다." + ) + @PatchMapping("/role") + fun updateRole( + @CurrentMember actor: Member, + @RequestBody @Valid req: UpdateRoleRequest + ): ResponseEntity> { + val body = memberService.updateRole(actor, req) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } } \ No newline at end of file From de76034f8a57d1690d5360e93ba9fb55431924ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 5 Nov 2025 23:36:01 +0900 Subject: [PATCH 312/470] =?UTF-8?q?chore:=20AbsenceApprovedType=20?= =?UTF-8?q?=EC=83=81=EC=88=98=20=EB=B3=84=20=EC=84=A4=EB=AA=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../absence/enums/AbsenceApprovedType.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt index 192b764..9229195 100644 --- a/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt +++ b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt @@ -1,5 +1,23 @@ package onku.backend.domain.absence.enums +import io.swagger.v3.oas.annotations.media.Schema + enum class AbsenceApprovedType { - EXCUSED, ABSENT, LATE, ABSENT_WITH_DOC, ABSENT_WITH_CAUSE, EARLY_LEAVE + @Schema(description = "사유서 인정(공결): 0점") + EXCUSED, + + @Schema(description = "결석(미제출): -3점") + ABSENT, + + @Schema(description = "결석(사유서 제출): -2점") + ABSENT_WITH_DOC, + + @Schema(description = "기타 사유(사유서 제출): -1점") + ABSENT_WITH_CAUSE, + + @Schema(description = "지각: -1점") + LATE, + + @Schema(description = "조퇴: -1점") + EARLY_LEAVE } \ No newline at end of file From 96f9249ce4b867a6bcd2b6acdfe82f2dd8edfe7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 6 Nov 2025 11:04:23 +0900 Subject: [PATCH 313/470] =?UTF-8?q?fix=20:=20projection=EB=A7=A4=ED=95=91?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=20#93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/absence/repository/AbsenceReportRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt index abd8b24..d8d2caf 100644 --- a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -13,7 +13,7 @@ interface AbsenceReportRepository : JpaRepository { """ select ar.id as absenceReportId, - ar.submitType as absenceType, + ar.submitType as absenceSubmitType, ar.approval as absenceReportApproval, ar.createdAt as submitDateTime, s.title as sessionTitle, From ff7e09752943cf1ba660cf05d3397b65a9992ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 6 Nov 2025 11:35:18 +0900 Subject: [PATCH 314/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20dto=EC=97=90=20=EC=B4=88=EA=B8=B0=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=EA=B0=84=20=EB=B0=8F=20=EB=A7=88=EC=A7=80?= =?UTF-8?q?=EB=A7=89=20=EC=88=98=EC=A0=95=20=EC=8B=9C=EA=B0=84=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80=20=ED=8F=AC=ED=95=A8=20?= =?UTF-8?q?#95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/dto/response/GetSessionNoticeResponse.kt | 5 +++++ .../onku/backend/domain/session/facade/SessionFacade.kt | 2 ++ 2 files changed, 7 insertions(+) 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 index 13f08f9..5909cf0 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/response/GetSessionNoticeResponse.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/GetSessionNoticeResponse.kt @@ -3,6 +3,7 @@ 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( @@ -26,4 +27,8 @@ data class GetSessionNoticeResponse( 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/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index 0e66d91..c2b2544 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -146,6 +146,8 @@ class SessionFacade( content = detail.content, images = imageDtos, isHoliday = session.isHoliday, + createdAt = session.createdAt, + updatedAt = session.updatedAt ) } fun deleteSession(sessionId: Long) { From 4f86b3d090f8211fecbaf9f845c8da4dc0f100dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 6 Nov 2025 14:08:39 +0900 Subject: [PATCH 315/470] =?UTF-8?q?fix:=20profile=20image=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95=20#96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/member/service/MemberProfileService.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index 2f66860..2831798 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -99,11 +99,14 @@ class MemberProfileService( 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 = profile.profileImage + profileImageUrl = url ) } From 36691c4498cac33bf1c77d0d97bbc730b8fa2cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 6 Nov 2025 21:07:43 +0900 Subject: [PATCH 316/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B3=B5=ED=9C=B4=EC=9D=BC=20=EC=84=B8=EC=85=98=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EC=B6=94=EA=B0=80=20=EB=B0=98=ED=99=98=20#99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/dto/response/GetInitialSessionResponse.kt | 4 +++- .../onku/backend/domain/session/service/SessionService.kt | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 index 979b45b..3630d96 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/response/GetInitialSessionResponse.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/GetInitialSessionResponse.kt @@ -14,5 +14,7 @@ data class GetInitialSessionResponse( @Schema(description = "세션 종류", example = "밋업프로젝트") val category : SessionCategory, @Schema(description = "세션 상세정보", example = "만약 null이면 세션정보 입력여부 false로 하면 됨") - val sessionDetailId : Long? + val sessionDetailId : Long?, + @Schema(description = "공휴일 세션 여부", example = "true") + val isHoliday : Boolean ) diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index 2b878cd..41c9071 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -84,7 +84,8 @@ class SessionService( startDate = s.startDate, title = s.title, category = s.category, - sessionDetailId = s.sessionDetail?.id + sessionDetailId = s.sessionDetail?.id, + isHoliday = s.isHoliday ) } } From ab3775507e193c076924c8024caa2684767f62d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 6 Nov 2025 21:20:56 +0900 Subject: [PATCH 317/470] =?UTF-8?q?feat=20:=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20#99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/session/Session.kt | 19 ++++++++++++++----- .../staff/SessionStaffController.kt | 12 ++++++++++++ .../domain/session/facade/SessionFacade.kt | 8 ++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/session/Session.kt b/src/main/kotlin/onku/backend/domain/session/Session.kt index a8816bc..dcffe60 100644 --- a/src/main/kotlin/onku/backend/domain/session/Session.kt +++ b/src/main/kotlin/onku/backend/domain/session/Session.kt @@ -1,6 +1,7 @@ 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 @@ -23,21 +24,29 @@ class Session( var sessionDetail: SessionDetail? = null, @Column(name = "title", nullable = false, length = 255) - val title: String, + var title: String, @Column(name = "start_date", nullable = false) - val startDate: LocalDate, + var startDate: LocalDate, @Enumerated(EnumType.STRING) @Column(name = "category", nullable = false, length = 32) - val category: SessionCategory, + var category: SessionCategory, @Column(name = "week", nullable = false, unique = true) - val week: Long, + var week: Long, var attendanceFinalized: Boolean = false, var attendanceFinalizedAt: LocalDateTime? = null, @Column(name = "is_holiday") var isHoliday : Boolean = false - ) : BaseEntity() + ) : 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/controller/staff/SessionStaffController.kt b/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt index dd1c32e..8e9d20e 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt @@ -106,4 +106,16 @@ class SessionStaffController( sessionFacade.deleteSession(id) return ResponseEntity.ok(SuccessResponse.ok(true)) } + + @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/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index c2b2544..b87b6c6 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -18,6 +18,7 @@ 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 @Component class SessionFacade( @@ -153,4 +154,11 @@ class SessionFacade( fun deleteSession(sessionId: Long) { sessionService.deleteCascade(sessionId) } + + @Transactional + fun patchSession(id: Long, sessionSaveRequest: SessionSaveRequest): Boolean { + val session = sessionService.getById(id) + session.update(sessionSaveRequest) + return true + } } \ No newline at end of file From 717c2f18fb5350989c45135d9ff0c85320725e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 6 Nov 2025 21:29:45 +0900 Subject: [PATCH 318/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=8B=9C=EA=B0=81,=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=EB=8F=84=20=EA=B0=99=EC=9D=B4=20=EC=A3=BC?= =?UTF-8?q?=EA=B8=B0=20#99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/dto/response/GetDetailSessionResponse.kt | 7 ++++++- .../onku/backend/domain/session/facade/SessionFacade.kt | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) 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 index b239dbe..4737c1c 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/response/GetDetailSessionResponse.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/GetDetailSessionResponse.kt @@ -2,6 +2,7 @@ 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( @@ -16,5 +17,9 @@ data class GetDetailSessionResponse( @Schema(description = "본문", example = "어쩌구 저쩌구") val content : String?, @Schema(description = "세션 이미지", example = "리스트형식") - val sessionImages : List + 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/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index b87b6c6..637de7d 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -112,7 +112,9 @@ class SessionFacade( startTime = detail.startTime, endTime = detail.endTime, content = detail.content, - sessionImages = imageDtos + sessionImages = imageDtos, + createdAt = detail.createdAt, + updatedAt = detail.updatedAt ) } From 82a642012efd45e91b8c71adaa55193ff7747ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 6 Nov 2025 21:47:47 +0900 Subject: [PATCH 319/470] =?UTF-8?q?refactor=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20ID=20=EA=B0=99=EC=9D=B4=EC=A3=BC=EA=B8=B0?= =?UTF-8?q?=20#99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/dto/response/GetDetailSessionResponse.kt | 2 ++ .../onku/backend/domain/session/facade/SessionFacade.kt | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) 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 index 4737c1c..428da87 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/response/GetDetailSessionResponse.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/response/GetDetailSessionResponse.kt @@ -6,6 +6,8 @@ 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") diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index 637de7d..bcdafee 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -91,9 +91,9 @@ class SessionFacade( } fun getSessionDetailPage(detailId: Long): GetDetailSessionResponse { - val detail = sessionDetailService.getById(detailId) val images = sessionImageService.findAllBySessionDetailId(detailId) - + val session = sessionService.getByDetailIdFetchDetail(detailId) + val detail = session.sessionDetail!! val imageDtos = images.map { image -> val preSignedUrl = s3Service.getGetS3Url( memberId = 0, @@ -107,6 +107,7 @@ class SessionFacade( } return GetDetailSessionResponse( + sessionId = session.id!!, sessionDetailId = detail.id!!, place = detail.place, startTime = detail.startTime, From df563dd1a84a467d3a451d1ea7b3f7c7a559b4d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 6 Nov 2025 21:48:23 +0900 Subject: [PATCH 320/470] =?UTF-8?q?refactor=20:=20fetch=20Join=EC=9D=84=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20=EC=BF=BC=EB=A6=AC=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20#99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/repository/SessionRepository.kt | 10 ++++++++++ .../backend/domain/session/service/SessionService.kt | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index 63e6604..eb914dc 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -134,4 +134,14 @@ interface SessionRepository : CrudRepository { @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? } diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt index 41c9071..a49c990 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionService.kt @@ -147,4 +147,10 @@ class SessionService( } 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 From 3be66dcf34c6b171231124d9fb2bbf5ec29b52f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 7 Nov 2025 00:09:16 +0900 Subject: [PATCH 321/470] =?UTF-8?q?fix=20:=20=EC=B6=9C=EC=84=9D=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20projection=20=EC=88=98=EC=A0=95(alias=EB=A7=A4?= =?UTF-8?q?=ED=95=91=EC=9D=B4=20=EC=95=88=20=EB=90=98=EC=96=B4=EC=84=9C=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4)=20#101?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/repository/AttendanceRepository.kt | 2 +- .../domain/attendance/service/AttendanceService.kt | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt index fb69165..884e014 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/repository/AttendanceRepository.kt @@ -11,7 +11,7 @@ import java.time.LocalDateTime interface StatusCountProjection { fun getStatus(): AttendancePointType - fun getCount(): Long + fun getCnt(): Long } interface AttendanceRepository : CrudRepository { diff --git a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt index a3d601e..1365d98 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/service/AttendanceService.kt @@ -73,18 +73,17 @@ class AttendanceService( rows.forEach { r -> when (r.getStatus()) { AttendancePointType.PRESENT, - AttendancePointType.PRESENT_HOLIDAY -> present += r.getCount() + AttendancePointType.PRESENT_HOLIDAY -> present += r.getCnt() - AttendancePointType.EARLY_LEAVE -> earlyLeave += r.getCount() - AttendancePointType.LATE -> late += r.getCount() + 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.getCount() + AttendancePointType.ABSENT_WITH_CAUSE -> absent += r.getCnt() } } - return WeeklyAttendanceSummary( present = present, earlyLeave = earlyLeave, @@ -149,7 +148,6 @@ class AttendanceService( } catch (e: DataIntegrityViolationException) { throw CustomException(AttendanceErrorCode.ATTENDANCE_ALREADY_RECORDED) } - val weeklySummary = getThisWeekSummary() return AttendanceResponse( From 29d96bf4ac90a521f93b140c21dbb91c5ee80983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 7 Nov 2025 14:24:41 +0900 Subject: [PATCH 322/470] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20UR?= =?UTF-8?q?L=EA=B3=BC=20=EB=82=98=EB=A8=B8=EC=A7=80=20=EC=98=A8=EB=B3=B4?= =?UTF-8?q?=EB=94=A9=20=EC=A0=95=EB=B3=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC=20#103?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/member/MemberProfile.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt b/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt index 8e17857..7f884f0 100644 --- a/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt +++ b/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt @@ -33,20 +33,21 @@ class MemberProfile( @Column(name = "profile_image", length = 2048) var profileImage: String? = null ) { - fun apply( name: String, school: String?, major: String?, part: Part, - phoneNumber: String?, - profileImage: String? + phoneNumber: String? ) { this.name = name this.school = school this.major = major this.part = part this.phoneNumber = phoneNumber - this.profileImage = profileImage + } + + fun updateProfileImage(url: String?) { + this.profileImage = url } } From 4fcd307f4d4de16cc0bc9fc9bccc649bf0d1ff3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 7 Nov 2025 14:25:21 +0900 Subject: [PATCH 323/470] =?UTF-8?q?fix:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20re?= =?UTF-8?q?quest=20dto=EC=97=90=EC=84=9C=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80URL=20=EC=A0=9C=EA=B1=B0=20#103?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt index cf40f32..3c1209e 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/OnboardingRequest.kt @@ -13,7 +13,4 @@ data class OnboardingRequest ( @Schema(description = "FCM 토큰", example = "eYJhbGciOi...") val fcmToken: String? = null, - - @Schema(description = "프로필 이미지 URL", example = "https://s3.../member_profile/1/uuid/profile.png") - val profileImage: String? = null ) From a53b1ffaf36f7f1f9a9cef2c442aff3d7c7b0b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 7 Nov 2025 14:25:39 +0900 Subject: [PATCH 324/470] =?UTF-8?q?fix:=20=EC=88=98=EC=A0=95=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20service=EC=97=90=20=EC=A0=81=EC=9A=A9=20#103?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/MemberProfileService.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index 2831798..14b4c80 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -59,7 +59,7 @@ class MemberProfileService( major = req.major, part = req.part, phoneNumber = req.phoneNumber, - profileImage = req.profileImage + profileImage = null ) memberProfileRepository.save(profile) } else { @@ -68,8 +68,7 @@ class MemberProfileService( school = req.school, major = req.major, part = req.part, - phoneNumber = req.phoneNumber, - profileImage = req.profileImage + phoneNumber = req.phoneNumber ) } } @@ -112,14 +111,14 @@ class MemberProfileService( @Transactional fun issueProfileImageUploadUrl(member: Member, fileName: String): GetUpdateAndDeleteUrlDto { - val signedUrlDto = s3Service.getPostS3Url( + val signed = s3Service.getPostS3Url( memberId = member.id!!, filename = fileName, folderName = FolderName.MEMBER_PROFILE.name, option = UploadOption.IMAGE ) - val oldKey = submitProfileImage(member, signedUrlDto.key) + val oldKey = submitProfileImage(member, signed.key) val oldDeletePreSignedUrl = if (!oldKey.isNullOrBlank()) { s3Service.getDeleteS3Url(oldKey).preSignedUrl @@ -128,7 +127,7 @@ class MemberProfileService( } return GetUpdateAndDeleteUrlDto( - newUrl = signedUrlDto.preSignedUrl, + newUrl = signed.preSignedUrl, oldUrl = oldDeletePreSignedUrl ) } @@ -138,7 +137,7 @@ class MemberProfileService( .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } val old = profile.profileImage - profile.profileImage = newKey + profile.updateProfileImage(newKey) return old } } \ No newline at end of file From 62cd2e981527220a7e238e2059c2f04f6eeb9d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 8 Nov 2025 17:46:40 +0900 Subject: [PATCH 325/470] =?UTF-8?q?refactor=20:=20application=5Furl?= =?UTF-8?q?=EC=9D=B4=20null=EA=B0=92=EC=9D=BC=20=EB=95=8C=20null=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B5=AC=ED=98=84=20#92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index d1f7373..6762e30 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -66,7 +66,7 @@ class KupickFacade( val applicationUrl: String? = p.kupick.applicationImageUrl - .takeIf { it.isNotBlank() } + ?.takeIf { it.isNotBlank() } //여기서 만약 applicationImageUrl이 null값이 들어가면 에러가 남(전체 학회원 큐픽 조회에 영향을 끼침) 그래서 safe call 추가 ?.let { key -> s3Service.getGetS3Url(memberId, key).preSignedUrl } val viewUrl: String? = p.kupick.viewImageUrl From b868346eede7a5db6e4a3262cf50fbbada37e05c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 8 Nov 2025 20:41:51 +0900 Subject: [PATCH 326/470] =?UTF-8?q?refactor=20:=20=EB=B6=88=EC=B0=B8?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EC=84=9C=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EA=B6=8C=ED=95=9C=20=EB=B3=84=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0(=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99)=20#?= =?UTF-8?q?85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/absence/controller/{ => user}/AbsenceController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/kotlin/onku/backend/domain/absence/controller/{ => user}/AbsenceController.kt (96%) diff --git a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/user/AbsenceController.kt similarity index 96% rename from src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt rename to src/main/kotlin/onku/backend/domain/absence/controller/user/AbsenceController.kt index 1a2fac8..d36e9fb 100644 --- a/src/main/kotlin/onku/backend/domain/absence/controller/AbsenceController.kt +++ b/src/main/kotlin/onku/backend/domain/absence/controller/user/AbsenceController.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.absence.controller +package onku.backend.domain.absence.controller.user import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag From fc292083c5ebacfa02cf8129eaef3507b0555ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 8 Nov 2025 20:43:50 +0900 Subject: [PATCH 327/470] =?UTF-8?q?refactor=20:=20member=EC=97=AD=EB=B0=A9?= =?UTF-8?q?=ED=96=A5=20=EB=A7=A4=ED=95=91(=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=EC=9A=A9=20=EB=B6=88=EC=B0=B8=EC=82=AC=EC=9C=A0=EC=84=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=8B=9C=EC=97=90=20?= =?UTF-8?q?memberProfile=EA=B9=8C=EC=A7=80=20join=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8F=84=EB=A1=9D=20=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=9C=84=ED=95=A8)=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/member/Member.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/member/Member.kt b/src/main/kotlin/onku/backend/domain/member/Member.kt index 97c981e..753a49b 100644 --- a/src/main/kotlin/onku/backend/domain/member/Member.kt +++ b/src/main/kotlin/onku/backend/domain/member/Member.kt @@ -40,7 +40,10 @@ class Member( var isStaff: Boolean = false, @Column(name = "fcm_token") - var fcmToken: String? = null + var fcmToken: String? = null, + + @OneToOne(mappedBy = "member", fetch = FetchType.LAZY) + var memberProfile: MemberProfile? = null ) : BaseEntity() { fun approve() { this.approval = ApprovalStatus.APPROVED From a049242fdc1f95deb5f416d06982fe81de8a3917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sat, 8 Nov 2025 20:44:56 +0900 Subject: [PATCH 328/470] =?UTF-8?q?feat=20:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=EC=9A=A9=20=EB=B6=88=EC=B0=B8=EC=82=AC=EC=9C=A0=EC=84=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/AbsenceManagerController.kt | 26 ++++++++++++++ .../GetMemberAbsenceReportResponse.kt | 27 ++++++++++++++ .../domain/absence/facade/AbsenceFacade.kt | 36 ++++++++++++++++++- .../repository/AbsenceReportRepository.kt | 9 +++++ .../domain/absence/service/AbsenceService.kt | 5 +++ .../global/auth/config/SecurityConfig.kt | 3 +- 6 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/absence/controller/manager/AbsenceManagerController.kt create mode 100644 src/main/kotlin/onku/backend/domain/absence/dto/response/GetMemberAbsenceReportResponse.kt 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..de2c6b2 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/controller/manager/AbsenceManagerController.kt @@ -0,0 +1,26 @@ +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.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.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@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))) + } +} \ 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..4419692 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMemberAbsenceReportResponse.kt @@ -0,0 +1,27 @@ +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 = "제출일시", 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/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index efb32ca..c711380 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -1,7 +1,10 @@ package onku.backend.domain.absence.facade 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 @@ -19,7 +22,7 @@ class AbsenceFacade( private val absenceService : AbsenceService, private val s3Service: S3Service, private val sessionService: SessionService, - private val sessionValidator: SessionValidator + private val sessionValidator: SessionValidator, ) { fun submitAbsenceReport(member: Member, submitAbsenceReportRequest: SubmitAbsenceReportRequest): GetPreSignedUrlDto { val session = sessionService.getById(submitAbsenceReportRequest.sessionId) @@ -55,4 +58,35 @@ class AbsenceFacade( 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, + submitDate = submitDate, + submitType = report.submitType, + time = time, + reason = report.reason, + url = preSignedUrl, + absenceApprovedType = when (report.approval) { + AbsenceReportApproval.SUBMIT -> null + AbsenceReportApproval.APPROVED -> report.approvedType + } + ) + } + } + } \ 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 index d8d2caf..8e1b3d7 100644 --- a/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt +++ b/src/main/kotlin/onku/backend/domain/absence/repository/AbsenceReportRepository.kt @@ -39,4 +39,13 @@ interface AbsenceReportRepository : JpaRepository { @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/service/AbsenceService.kt b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt index ed4cd19..73c70c1 100644 --- a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -48,4 +48,9 @@ class AbsenceService( ) } } + + @Transactional(readOnly = true) + fun getBySessionId(sessionId : Long) : List { + return absenceReportRepository.findAllBySessionId(sessionId) + } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index bc41cf4..2800ecb 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -43,7 +43,8 @@ class SecurityConfig( private val MANAGEMENT_ENDPOINT = arrayOf( // 경총 "/api/v1/kupick/manage/**", "/api/v1/points/manage/**", - "/api/v1/attendance/manage/**" + "/api/v1/attendance/manage/**", + "/api/v1/absence/manage/**" ) // private val EXECUTIVE = arrayOf("") // 회장단 From c4aab9711b3e5a7fe968195a7890510aca7c265a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 11 Nov 2025 11:04:19 +0900 Subject: [PATCH 329/470] =?UTF-8?q?refactor:=20request=20=EA=B0=92?= =?UTF-8?q?=EC=97=90=20YearMonth=20=EA=B0=92=20=EC=B6=94=EA=B0=80=20#89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/point/dto/UpdateManualPointRequest.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt b/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt index c097c7f..05b4f1c 100644 --- a/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt +++ b/src/main/kotlin/onku/backend/domain/point/dto/UpdateManualPointRequest.kt @@ -2,11 +2,17 @@ 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? = null + @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( From fd6c52fa30ef8b58441e403103666eae5e8ef70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 11 Nov 2025 11:06:10 +0900 Subject: [PATCH 330/470] =?UTF-8?q?refactor:=20kupick=20=EA=B8=B0=EC=A1=B4?= =?UTF-8?q?=20=EB=A0=88=EC=BD=94=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EC=9D=80=20request=EC=9D=98=20YearMonth?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=ED=95=98=EB=A9=B0,=20MemberPoint?= =?UTF-8?q?History=20=EB=A0=88=EC=BD=94=EB=93=9C=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EA=B0=81(=EC=88=98=EC=A0=95=EC=8B=9C?= =?UTF-8?q?=EA=B0=81)=EC=9C=BC=EB=A1=9C=20=EA=B8=B0=EB=A1=9D=20#89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/service/AdminPointCommandService.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt index 3cad1aa..c92a7d1 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt @@ -17,6 +17,7 @@ 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( @@ -122,22 +123,23 @@ class AdminPointCommandService( } @Transactional - fun updateKupickApproval(memberId: Long): KupickApprovalResult { + 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 = LocalDate.now(clock).withDayOfMonth(1).atStartOfDay() - val startOfNextMonth = startOfMonth.toLocalDate().plusMonths(1).atStartOfDay() - val existing = kupickRepository.findThisMonthByMember( + 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, now) + val target = existing ?: run { // 없으면 생성 + val created = Kupick.createKupick(member, startOfMonth) kupickRepository.save(created) } @@ -149,7 +151,7 @@ class AdminPointCommandService( MemberPointHistory.ofManual( member = member, manualType = ManualPointType.KUPICK, - occurredAt = now, + occurredAt = now, // MemberPointHistory 레코드에는 수정시각(현재)로 기록 points = diff ) ) From c941b6ad3a2260865685062b57ec4b28c352b215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 11 Nov 2025 11:06:53 +0900 Subject: [PATCH 331/470] =?UTF-8?q?refactor:=20YearMonth=20request?= =?UTF-8?q?=EB=8F=84=20=EC=B6=94=EA=B0=80=ED=95=B4=EC=84=9C=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/manager/PointManagerController.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 index 0fa538d..d4b6cee 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt @@ -10,6 +10,7 @@ import onku.backend.global.page.PageResponse import onku.backend.global.response.SuccessResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import java.time.YearMonth @RestController @RequestMapping("/api/v1/points/manage") @@ -81,12 +82,16 @@ class PointManagerController( @PatchMapping("/kupick") @Operation( - summary = "이번 달 큐픽 승인 토글", - description = "memberId만 받아 이번 달 큐픽 승인 여부를 반전시킵니다. 제출 레코드가 없으면 생성합니다." + summary = "이번 달 큐픽 제출여부 수정 토글", + description = "memberId, YearMonth값을 받아 이번 달 큐픽 승인 여부를 반전시킵니다. 제출 레코드가 없으면 생성합니다." ) fun updateKupick(@RequestBody @Valid req: ToggleMemberRequest) : ResponseEntity> { - val result = commandService.updateKupickApproval(req.memberId!!) + val ym = YearMonth.parse(req.yearMonth) + val result = commandService.updateKupickApproval( + memberId = req.memberId, + targetYm = ym + ) return ResponseEntity.ok(SuccessResponse.ok(result)) } From 8c768a5f2a7e22d8da514abf4a0d78353eaad0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 11 Nov 2025 18:37:35 +0900 Subject: [PATCH 332/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=88=98=EC=A0=95=EC=9A=A9=20request,=20response?= =?UTF-8?q?=20dto=20=EC=B6=94=EA=B0=80=20#109?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/dto/UpdateAttendanceStatusRequest.kt | 13 +++++++++++++ .../point/dto/UpdateAttendanceStatusResponse.kt | 14 ++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateAttendanceStatusRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/point/dto/UpdateAttendanceStatusResponse.kt 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 From e5d91d11c8a03360283efb352aafe5883f9063ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 11 Nov 2025 18:38:06 +0900 Subject: [PATCH 333/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=BD=94=EB=93=9C=20enum=20=EC=B6=94=EA=B0=80=20#1?= =?UTF-8?q?09?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/attendance/AttendanceErrorCode.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/attendance/AttendanceErrorCode.kt b/src/main/kotlin/onku/backend/domain/attendance/AttendanceErrorCode.kt index c24497d..a357b2e 100644 --- a/src/main/kotlin/onku/backend/domain/attendance/AttendanceErrorCode.kt +++ b/src/main/kotlin/onku/backend/domain/attendance/AttendanceErrorCode.kt @@ -11,4 +11,6 @@ enum class AttendanceErrorCode( 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 From 4d611a852073cdc43826c4159277c26c00502c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 11 Nov 2025 18:39:45 +0900 Subject: [PATCH 334/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20MemberPointHistory=EC=97=90=20=EB=A0=88?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=82=BD=EC=9E=85=ED=95=A0=20?= =?UTF-8?q?=EB=95=8C=20=EC=82=AC=EC=9A=A9=ED=95=A0=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?ofAttendanceUpdate=20=EC=B6=94=EA=B0=80=20#109?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/point/MemberPointHistory.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt index 058ff0b..57ff9ae 100644 --- a/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt +++ b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt @@ -93,6 +93,49 @@ class MemberPointHistory( } } + 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, From 2f182a4aaa113ff8c2214fe38d513ed3a9ae7403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 11 Nov 2025 18:40:35 +0900 Subject: [PATCH 335/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C?= =?UTF-8?q?=20Attendance=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=9D=98=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20MemberPoi?= =?UTF-8?q?ntHistory=EC=97=90=20=EC=88=98=EC=A0=95=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EC=82=BD=EC=9E=85=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#109?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/service/AdminPointCommandService.kt | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt index 3cad1aa..ae6acdb 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointCommandService.kt @@ -1,5 +1,8 @@ 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 @@ -11,6 +14,8 @@ 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 @@ -24,6 +29,8 @@ class AdminPointCommandService( private val memberRepository: MemberRepository, private val kupickRepository: KupickRepository, private val memberPointHistoryRepository: MemberPointHistoryRepository, + private val attendanceRepository: AttendanceRepository, + private val sessionRepository: SessionRepository, private val clock: Clock ) { @@ -174,4 +181,67 @@ class AdminPointCommandService( 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 From b116d979def58461d88b0000643babfda09b87db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 11 Nov 2025 18:41:01 +0900 Subject: [PATCH 336/470] =?UTF-8?q?feat:=20=EC=9B=94=EB=B3=84=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=EC=83=81=ED=83=9C=20=EC=88=98=EC=A0=95=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#109?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/PointManagerController.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index 0fa538d..e336857 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt @@ -105,4 +105,21 @@ class PointManagerController( val body = adminPointsService.getMonthlyPaged(year, month, safePage, size) return ResponseEntity.ok(SuccessResponse.ok(body)) } + + @PatchMapping("/monthly") + @Operation( + summary = "월별 출석 상태 수정 [운영진]", + description = "attendanceId, memberId, status를 받아 출석 상태를 수정하고, 상벌점 변동을 MemberPointHistory에 기록합니다." + ) + 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 From ab39626d67bd8338cbf6150766ead3a1835d194c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 12 Nov 2025 02:15:12 +0900 Subject: [PATCH 337/470] =?UTF-8?q?refactor=20:=20=EB=B6=88=EC=B0=B8?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EC=84=9C=20=EC=8A=B9=EC=9D=B8=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=97=90=20=EC=A0=90=EC=88=98=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/absence/enums/AbsenceApprovedType.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt index 9229195..826d350 100644 --- a/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt +++ b/src/main/kotlin/onku/backend/domain/absence/enums/AbsenceApprovedType.kt @@ -2,22 +2,25 @@ package onku.backend.domain.absence.enums import io.swagger.v3.oas.annotations.media.Schema -enum class AbsenceApprovedType { +enum class AbsenceApprovedType( + @Schema(description = "해당 출석 상태의 점수") + val points: Int +) { @Schema(description = "사유서 인정(공결): 0점") - EXCUSED, + EXCUSED(0), @Schema(description = "결석(미제출): -3점") - ABSENT, + ABSENT(-3), @Schema(description = "결석(사유서 제출): -2점") - ABSENT_WITH_DOC, + ABSENT_WITH_DOC(-2), @Schema(description = "기타 사유(사유서 제출): -1점") - ABSENT_WITH_CAUSE, + ABSENT_WITH_CAUSE(-1), @Schema(description = "지각: -1점") - LATE, + LATE(-1), @Schema(description = "조퇴: -1점") - EARLY_LEAVE + EARLY_LEAVE(-1) } \ No newline at end of file From 9081c81bd86142e2393509f21c9ae7fccf2f66bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 12 Nov 2025 02:15:31 +0900 Subject: [PATCH 338/470] =?UTF-8?q?feat=20:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=EC=95=8C=EB=A6=BC=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?#108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/alarm/AlarmMessage.kt | 6 ++++++ src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt | 1 + 2 files changed, 7 insertions(+) diff --git a/src/main/kotlin/onku/backend/global/alarm/AlarmMessage.kt b/src/main/kotlin/onku/backend/global/alarm/AlarmMessage.kt index 41c80aa..7627d0b 100644 --- a/src/main/kotlin/onku/backend/global/alarm/AlarmMessage.kt +++ b/src/main/kotlin/onku/backend/global/alarm/AlarmMessage.kt @@ -1,5 +1,7 @@ package onku.backend.global.alarm +import onku.backend.domain.absence.enums.AbsenceApprovedType + object AlarmMessage { fun kupick(month : Int, status : Boolean): String { if(status) { @@ -7,4 +9,8 @@ object AlarmMessage { } 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/AlarmTitle.kt b/src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt index b5049d3..d84c09c 100644 --- a/src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt +++ b/src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt @@ -2,4 +2,5 @@ package onku.backend.global.alarm object AlarmTitle { const val KUPICK = "큐픽 관련 알림입니다." + const val ABSENCE_REPORT = "불참 사유서 관련 알림입니다." } \ No newline at end of file From 829f064b1b955114d10e32a4a0c650c9eb87ded4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 12 Nov 2025 02:15:59 +0900 Subject: [PATCH 339/470] =?UTF-8?q?feat=20:=20=EB=B2=8C=EC=A0=90=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=EB=AC=B8=20=EC=B6=94=EA=B0=80=20#108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MemberPointHistoryService.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt index 1861eec..694e8ce 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/MemberPointHistoryService.kt @@ -1,8 +1,11 @@ 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 @@ -10,6 +13,7 @@ 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( @@ -46,4 +50,26 @@ class MemberPointHistoryService( 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 + ) + } } From 7531d013001df6ceb3212b1723ceb7617b038dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 12 Nov 2025 02:16:59 +0900 Subject: [PATCH 340/470] =?UTF-8?q?feat=20:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=ED=8F=89=EA=B0=80=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80=20#108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/absence/AbsenceReport.kt | 14 ++++++++++- .../manager/AbsenceManagerController.kt | 14 +++++++---- .../request/EstimateAbsenceReportRequest.kt | 9 ++++++++ .../GetMemberAbsenceReportResponse.kt | 2 ++ .../domain/absence/facade/AbsenceFacade.kt | 23 +++++++++++++++++++ .../domain/absence/service/AbsenceService.kt | 9 ++++++++ .../domain/point/MemberPointHistory.kt | 15 +++++++++--- .../MemberPointHistoryRepository.kt | 2 ++ 8 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/absence/dto/request/EstimateAbsenceReportRequest.kt diff --git a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt index 06b5b84..eccbc94 100644 --- a/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt +++ b/src/main/kotlin/onku/backend/domain/absence/AbsenceReport.kt @@ -50,7 +50,7 @@ class AbsenceReport( @Column(name = "approval") @Enumerated(EnumType.STRING) - val approval : AbsenceReportApproval, + var approval : AbsenceReportApproval, @Column(name = "lateDateTime") var lateDateTime: LocalDateTime?, @@ -92,4 +92,16 @@ class AbsenceReport( 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/controller/manager/AbsenceManagerController.kt b/src/main/kotlin/onku/backend/domain/absence/controller/manager/AbsenceManagerController.kt index de2c6b2..7a45601 100644 --- a/src/main/kotlin/onku/backend/domain/absence/controller/manager/AbsenceManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/absence/controller/manager/AbsenceManagerController.kt @@ -2,14 +2,12 @@ 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.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/absence/manage") @@ -23,4 +21,12 @@ class AbsenceManagerController( : 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/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/response/GetMemberAbsenceReportResponse.kt b/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMemberAbsenceReportResponse.kt index 4419692..a5f9f8b 100644 --- a/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMemberAbsenceReportResponse.kt +++ b/src/main/kotlin/onku/backend/domain/absence/dto/response/GetMemberAbsenceReportResponse.kt @@ -12,6 +12,8 @@ data class GetMemberAbsenceReportResponse( 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") diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index c711380..c37e8a3 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -1,5 +1,6 @@ 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 @@ -8,14 +9,20 @@ 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.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.AlarmTitle +import onku.backend.global.alarm.FCMService 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( @@ -23,6 +30,8 @@ class AbsenceFacade( 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) @@ -76,6 +85,7 @@ class AbsenceFacade( GetMemberAbsenceReportResponse( name = memberProfile.name ?: "UNKNOWN", part = memberProfile.part, + absenceReportId = report.id!!, submitDate = submitDate, submitType = report.submitType, time = time, @@ -89,4 +99,17 @@ class AbsenceFacade( } } + @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() + if(!absenceReport.member.fcmToken.isNullOrBlank()){ + fcmService.sendMessageTo(absenceReport.member.fcmToken!!, AlarmTitle.ABSENCE_REPORT, 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/service/AbsenceService.kt b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt index 73c70c1..3aa058e 100644 --- a/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt +++ b/src/main/kotlin/onku/backend/domain/absence/service/AbsenceService.kt @@ -1,11 +1,13 @@ 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 @@ -53,4 +55,11 @@ class AbsenceService( 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/point/MemberPointHistory.kt b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt index 058ff0b..459b408 100644 --- a/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt +++ b/src/main/kotlin/onku/backend/domain/point/MemberPointHistory.kt @@ -32,13 +32,13 @@ class MemberPointHistory( val category: PointCategory, @Column(name = "type", nullable = false) - val type: String, + var type: String, @Column(name = "points", nullable = false) - val points: Int, + var points: Int, @Column(name = "occurred_at", nullable = false) - val occurredAt: LocalDateTime, + var occurredAt: LocalDateTime, @Column(name = "week") val week: Long? = null, @@ -108,4 +108,13 @@ class MemberPointHistory( ) } } + + 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/repository/MemberPointHistoryRepository.kt b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt index abdeec0..09e3064 100644 --- a/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt +++ b/src/main/kotlin/onku/backend/domain/point/repository/MemberPointHistoryRepository.kt @@ -72,4 +72,6 @@ interface MemberPointHistoryRepository : JpaRepository @Param("start") start: LocalDateTime, @Param("end") end: LocalDateTime ): List + + fun findByWeekAndMember(week : Long, member: Member) : MemberPointHistory? } \ No newline at end of file From 5dc75b3b7af4bc004e74eaf0ca5124b4edb179fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 12 Nov 2025 02:17:16 +0900 Subject: [PATCH 341/470] =?UTF-8?q?chore=20:=20=EB=B6=88=EC=B0=B8=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=84=9C=20=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/absence/AbsenceReportErrorCode.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/absence/AbsenceReportErrorCode.kt 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 From 8b0f8250b861f09d03be91d3453ad9bd9db65ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 12 Nov 2025 02:17:37 +0900 Subject: [PATCH 342/470] =?UTF-8?q?chore=20:=20fcm=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/alarm/FCMService.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/onku/backend/global/alarm/FCMService.kt b/src/main/kotlin/onku/backend/global/alarm/FCMService.kt index 3b38dc3..04185fa 100644 --- a/src/main/kotlin/onku/backend/global/alarm/FCMService.kt +++ b/src/main/kotlin/onku/backend/global/alarm/FCMService.kt @@ -45,6 +45,8 @@ class FCMService( .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8") .build() + log.info("제목 : $title, 내용 : $body") + client.newCall(request).execute().use { response -> log.info("fcm 결과 : " + response.body?.string()) } From 87a3b18604f5c84da99c6ba42e5440821f53ed20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 12 Nov 2025 02:28:43 +0900 Subject: [PATCH 343/470] =?UTF-8?q?refactor=20:=20=EC=A7=80=EB=82=9C=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=88=98=EC=A0=95=20=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=8B=9C=20=EC=97=90=EB=9F=AC=20=EB=B0=98=ED=99=98=20#112?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/session/facade/SessionFacade.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index bcdafee..6417a3c 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -1,6 +1,7 @@ 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 @@ -11,6 +12,7 @@ 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 @@ -19,6 +21,7 @@ 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.LocalDate @Component class SessionFacade( @@ -161,6 +164,9 @@ class SessionFacade( @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 } From fba3d9481cdf08809b74125821160f9fa2ff5fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 12 Nov 2025 18:54:33 +0900 Subject: [PATCH 344/470] =?UTF-8?q?feat:=20Notice,=20NoticeCategory,=20Not?= =?UTF-8?q?iceImage=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80=20#?= =?UTF-8?q?115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/notice/Notice.kt | 43 +++++++++++++++++++ .../backend/domain/notice/NoticeCategory.kt | 29 +++++++++++++ .../onku/backend/domain/notice/NoticeImage.kt | 22 ++++++++++ 3 files changed, 94 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/Notice.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/NoticeCategory.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/NoticeImage.kt 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..ad7b08c --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/Notice.kt @@ -0,0 +1,43 @@ +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_map", + joinColumns = [JoinColumn(name = "notice_id")], + inverseJoinColumns = [JoinColumn(name = "category_id")] + ) + var categories: MutableSet = linkedSetOf() + + @OneToMany(mappedBy = "notice", cascade = [CascadeType.ALL], orphanRemoval = true) + var images: MutableList = mutableListOf() +} 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/NoticeImage.kt b/src/main/kotlin/onku/backend/domain/notice/NoticeImage.kt new file mode 100644 index 0000000..afcce6f --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/NoticeImage.kt @@ -0,0 +1,22 @@ +package onku.backend.domain.notice + +import jakarta.persistence.* + +@Entity +@Table(name = "notice_image") +class NoticeImage( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notice_image_id") + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "notice_id", nullable = false) + val notice: Notice, + + @Column(name = "image_url", nullable = false, length = 1024) + val imageUrl: String, + + @Column(name = "sort_order", nullable = false) + val sortOrder: Int = 0 +) From 3bb68467a26de9b6841712e87395152fba82049e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 12 Nov 2025 18:55:07 +0900 Subject: [PATCH 345/470] =?UTF-8?q?feat:=20notice=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/notice/NoticeErrorCode.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/NoticeErrorCode.kt 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..fe157b9 --- /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 { + + // Category + 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 From 201b3f02b7925c286a7ac02e1f59cf5aa77e8220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 12 Nov 2025 18:56:37 +0900 Subject: [PATCH 346/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EA=B4=80=EB=A0=A8=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=9A=A9=20dto=20=EC=B6=94=EA=B0=80=20#115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/category/AvailableColorsResponse.kt | 7 +++++++ .../category/NoticeCategoryCreateRequest.kt | 12 ++++++++++++ .../dto/category/NoticeCategoryResponse.kt | 18 ++++++++++++++++++ .../category/NoticeCategoryUpdateRequest.kt | 12 ++++++++++++ 4 files changed, 49 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/category/AvailableColorsResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryCreateRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/category/NoticeCategoryUpdateRequest.kt 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 +) From fa71e41138592ce2dec5f42a084b11ffed19164d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 12 Nov 2025 18:57:15 +0900 Subject: [PATCH 347/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98,=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=83=89?= =?UTF-8?q?=EC=83=81=20=EC=83=81=EC=88=98=20=EC=B6=94=EA=B0=80=20#115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/notice/enums/NoticeCategoryColor.kt | 5 +++++ .../kotlin/onku/backend/domain/notice/enums/NoticeStatus.kt | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/enums/NoticeStatus.kt 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..6fb5495 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt @@ -0,0 +1,5 @@ +package onku.backend.domain.notice.enums + +enum class NoticeCategoryColor { + RED, ORANGE, YELLOW, GREEN, TEAL, BLUE, INDIGO, PURPLE, PINK, BROWN, GRAY +} \ 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 +} From 02d585afbf36f828f97c82a857955e15d39d4358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 12 Nov 2025 18:58:35 +0900 Subject: [PATCH 348/470] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=A4=91=EB=B3=B5=20=EB=B0=8F=20=EA=B3=B5=EC=A7=80?= =?UTF-8?q?=20=EC=97=B0=EA=B4=80=20=EC=97=AC=EB=B6=80=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EB=93=B1=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=98=EB=8A=94=20?= =?UTF-8?q?repository=20=EC=BD=94=EB=93=9C=EB=93=A4=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20#115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/NoticeCategoryRepository.kt | 24 +++++++++++++++++++ .../repository/NoticeImageRepository.kt | 6 +++++ .../notice/repository/NoticeRepository.kt | 6 +++++ 3 files changed, 36 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/repository/NoticeCategoryRepository.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/repository/NoticeImageRepository.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt 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/NoticeImageRepository.kt b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeImageRepository.kt new file mode 100644 index 0000000..49e6bb1 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeImageRepository.kt @@ -0,0 +1,6 @@ +package onku.backend.domain.notice.repository + +import onku.backend.domain.notice.NoticeImage +import org.springframework.data.jpa.repository.JpaRepository + +interface NoticeImageRepository : JpaRepository 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..63943e8 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt @@ -0,0 +1,6 @@ +package onku.backend.domain.notice.repository + +import onku.backend.domain.notice.Notice +import org.springframework.data.jpa.repository.JpaRepository + +interface NoticeRepository : JpaRepository \ No newline at end of file From d97ed4e2e0a4097a48d0eec7dfb6bd90da125cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 12 Nov 2025 18:59:37 +0900 Subject: [PATCH 349/470] =?UTF-8?q?feat:=20NoticeCategoryService=EC=97=90?= =?UTF-8?q?=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=C2=B7=EC=88=98=EC=A0=95=C2=B7=EC=82=AD=EC=A0=9C=C2=B7=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=C2=B7=EC=83=89=EC=83=81=EC=A1=B0=ED=9A=8C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B0=8F=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/service/NoticeCategoryService.kt | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/service/NoticeCategoryService.kt 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..1e8291f --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeCategoryService.kt @@ -0,0 +1,85 @@ +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 { + validateNameLength(req.name) + 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 { + validateNameLength(req.name) + + 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) + } + + private fun validateNameLength(name: String) { + if (name.trim().length >= 7) { + throw CustomException(NoticeErrorCode.CATEGORY_NAME_TOO_LONG) + } + } +} From 103d194938f6184c71e335f4ceff3033a0adc426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 12 Nov 2025 19:01:23 +0900 Subject: [PATCH 350/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20API=20=EC=B6=94=EA=B0=80=20#115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/NoticeCategoryController.kt | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/controller/NoticeCategoryController.kt diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeCategoryController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeCategoryController.kt new file mode 100644 index 0000000..e1c4881 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeCategoryController.kt @@ -0,0 +1,67 @@ +package onku.backend.domain.notice.controller + +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)) + } +} From 74401854c52b434cdf076018599302538cc094c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 12 Nov 2025 19:07:59 +0900 Subject: [PATCH 351/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=EB=93=A4=EC=97=90=20STAFF=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=84=A4=EC=A0=95=20#115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/global/auth/config/SecurityConfig.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index 2800ecb..eec26cc 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -37,7 +37,8 @@ class SecurityConfig( private val STAFF_ENDPOINT = arrayOf( // 운영진 "/api/v1/session/staff/**", - "/api/v1/members/*/approval" + "/api/v1/members/*/approval", + "/api/v1/notice/categories/**" ) private val MANAGEMENT_ENDPOINT = arrayOf( // 경총 From 1db47b5720f38b35d312464cdb7434a8e50a447d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 12 Nov 2025 22:35:07 +0900 Subject: [PATCH 352/470] =?UTF-8?q?refactor=20:=20webp=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=ED=97=88=EC=9A=A9=20#118?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/s3/service/S3Service.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt index 65c1c55..049f8b9 100644 --- a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -140,6 +140,7 @@ class S3Service( lower.endsWith(".jpg") || lower.endsWith(".jpeg") -> "image/jpeg" lower.endsWith(".png") -> "image/png" lower.endsWith(".heic") -> "image/heic" + lower.endsWith(".webp") -> "image/webp" else -> throw CustomException(ErrorCode.INVALID_FILE_EXTENSION) } } From fc8f8e62f8f9d58657f6be23aea79262f6fd8b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 12 Nov 2025 23:14:11 +0900 Subject: [PATCH 353/470] =?UTF-8?q?fix:=20=EB=A7=A4=ED=95=91=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EB=AA=85=20ERD=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20#115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/notice/Notice.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/Notice.kt b/src/main/kotlin/onku/backend/domain/notice/Notice.kt index ad7b08c..88d2f81 100644 --- a/src/main/kotlin/onku/backend/domain/notice/Notice.kt +++ b/src/main/kotlin/onku/backend/domain/notice/Notice.kt @@ -32,7 +32,7 @@ class Notice( ) { @ManyToMany @JoinTable( - name = "notice_category_map", + name = "notice_category", joinColumns = [JoinColumn(name = "notice_id")], inverseJoinColumns = [JoinColumn(name = "category_id")] ) From bc102af13f9221454ecccdcc77baff07b7908271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 12 Nov 2025 23:17:57 +0900 Subject: [PATCH 354/470] =?UTF-8?q?refactor:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=B4=EB=A6=84=20=EA=B8=80=EC=9E=90=EC=88=98=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=A4=91=EB=B3=B5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20#115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/service/NoticeCategoryService.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/service/NoticeCategoryService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeCategoryService.kt index 1e8291f..2d2c8d8 100644 --- a/src/main/kotlin/onku/backend/domain/notice/service/NoticeCategoryService.kt +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeCategoryService.kt @@ -13,10 +13,8 @@ import onku.backend.global.exception.CustomException class NoticeCategoryService( private val categoryRepository: NoticeCategoryRepository ) { - @Transactional fun create(req: NoticeCategoryCreateRequest): NoticeCategoryResponse { - validateNameLength(req.name) if (categoryRepository.existsByName(req.name)) { throw CustomException(NoticeErrorCode.CATEGORY_NAME_DUPLICATE) } @@ -35,8 +33,6 @@ class NoticeCategoryService( @Transactional fun update(categoryId: Long, req: NoticeCategoryUpdateRequest): NoticeCategoryResponse { - validateNameLength(req.name) - val category = categoryRepository.findById(categoryId) .orElseThrow { CustomException(NoticeErrorCode.CATEGORY_NOT_FOUND) } @@ -76,10 +72,4 @@ class NoticeCategoryService( val available = (all - used).sortedBy { it.name } return AvailableColorsResponse(available) } - - private fun validateNameLength(name: String) { - if (name.trim().length >= 7) { - throw CustomException(NoticeErrorCode.CATEGORY_NAME_TOO_LONG) - } - } } From ea7a6f4a6b28763b5f037c733910f22854653d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 13 Nov 2025 14:12:32 +0900 Subject: [PATCH 355/470] =?UTF-8?q?fix:=20allowedMethods=EC=97=90=20PATCH?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20#120?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt b/src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt index 1ec173f..a00c6da 100644 --- a/src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt +++ b/src/main/kotlin/onku/backend/global/config/CustomCorsConfig.kt @@ -18,7 +18,7 @@ class CustomCorsConfig { "http://localhost:8080", "https://dev.ku-check.o-r.kr" ) - configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS") + configuration.allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") configuration.allowedHeaders = listOf("*") configuration.allowCredentials = true configuration.exposedHeaders = listOf("Authorization", "Set-Cookie", "X-Refresh-Token") From 54406d8858844433eeeb22b36eebd47398bbc0ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Fri, 14 Nov 2025 02:27:59 +0900 Subject: [PATCH 356/470] =?UTF-8?q?refactor=20:=20=ED=81=90=ED=94=BD=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=EC=97=AC=EB=B6=80=20=EB=AF=B8=EC=A0=95=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80=20#123?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/kupick/Kupick.kt | 6 +++++- .../domain/kupick/dto/response/ShowUpdateResponseDto.kt | 9 ++++++--- .../onku/backend/domain/kupick/facade/KupickFacade.kt | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt index 11bb50e..39ee667 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/Kupick.kt @@ -33,7 +33,10 @@ class Kupick( var viewDate : LocalDateTime? = null, @Column(name = "approval") - var approval : Boolean = false + var approval : Boolean = false, + + @Column(name = "isApprovalCheck") + var isApprovalCheck : Boolean = false ) : BaseEntity() { companion object { @@ -77,5 +80,6 @@ class Kupick( 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/dto/response/ShowUpdateResponseDto.kt b/src/main/kotlin/onku/backend/domain/kupick/dto/response/ShowUpdateResponseDto.kt index 5066047..926234d 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/dto/response/ShowUpdateResponseDto.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/dto/response/ShowUpdateResponseDto.kt @@ -19,8 +19,10 @@ data class ShowUpdateResponseDto( val applicationUrl : String?, @Schema(description = "시청사진Url", example = "https://~") val viewUrl : String?, - @Schema(description = "승인여부", example = "True") - val approval : Boolean + @Schema(description = "승인여부", example = "true") + val approval : Boolean, + @Schema(description = "승인여부 선택 여부", example = "false") + val isApprovalCheck : Boolean ) { companion object { fun of(memberProfile : MemberProfile, kupick : Kupick) = ShowUpdateResponseDto ( @@ -30,7 +32,8 @@ data class ShowUpdateResponseDto( kupick.submitDate, kupick.applicationImageUrl, kupick.viewImageUrl, - kupick.approval + kupick.approval, + kupick.isApprovalCheck ) } } diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index 6762e30..1dcddc1 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -80,7 +80,8 @@ class KupickFacade( submitDate = p.kupick.submitDate, applicationUrl = applicationUrl, viewUrl = viewUrl, - approval = p.kupick.approval + approval = p.kupick.approval, + isApprovalCheck = p.kupick.isApprovalCheck ) } return dtoList From 18d5531e99258ed0a176b0b07c6a62ee67a5c99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 14 Nov 2025 17:40:14 +0900 Subject: [PATCH 357/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EC=B2=A8?= =?UTF-8?q?=EB=B6=80=20=ED=8C=8C=EC=9D=BC=20=EC=A0=80=EC=9E=A5=EC=9A=A9=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B6=94=EA=B0=80=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/notice/NoticeAttachment.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/NoticeAttachment.kt 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..e349b95 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/NoticeAttachment.kt @@ -0,0 +1,24 @@ +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, +) From 8ad9f5ff50a1afb0b08901fe8fa865edd6ae1473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 14 Nov 2025 17:41:52 +0900 Subject: [PATCH 358/470] =?UTF-8?q?feat:=20=EC=B2=A8=EB=B6=80=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EA=B3=B5=EC=A7=80=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=98=EA=B1=B0=EB=82=98=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/notice/Notice.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/Notice.kt b/src/main/kotlin/onku/backend/domain/notice/Notice.kt index ad7b08c..ef0a074 100644 --- a/src/main/kotlin/onku/backend/domain/notice/Notice.kt +++ b/src/main/kotlin/onku/backend/domain/notice/Notice.kt @@ -39,5 +39,23 @@ class Notice( var categories: MutableSet = linkedSetOf() @OneToMany(mappedBy = "notice", cascade = [CascadeType.ALL], orphanRemoval = true) - var images: MutableList = mutableListOf() + 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) } + } } From 5f4d0b31ee8834e43370d0d2d5cc39e8a6a757e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 14 Nov 2025 17:42:43 +0900 Subject: [PATCH 359/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=83=81=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/notice/NoticeErrorCode.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/NoticeErrorCode.kt b/src/main/kotlin/onku/backend/domain/notice/NoticeErrorCode.kt index fe157b9..2ac8204 100644 --- a/src/main/kotlin/onku/backend/domain/notice/NoticeErrorCode.kt +++ b/src/main/kotlin/onku/backend/domain/notice/NoticeErrorCode.kt @@ -8,8 +8,8 @@ enum class NoticeErrorCode( override val message: String, override val status: HttpStatus ) : ApiErrorCode { + NOTICE_NOT_FOUND("NOT100", "존재하지 않는 공지입니다.", HttpStatus.NOT_FOUND), - // Category CATEGORY_NOT_FOUND("NOT001", "존재하지 않는 카테고리입니다.", HttpStatus.NOT_FOUND), CATEGORY_NAME_DUPLICATE("NOT002", "이미 존재하는 카테고리 이름입니다.", HttpStatus.CONFLICT), CATEGORY_COLOR_DUPLICATE("NOT003", "이미 사용 중인 카테고리 색상입니다.", HttpStatus.CONFLICT), From adaf6a5c54f8bd48a37a8a9cb1876d095ea7a03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 14 Nov 2025 17:43:11 +0900 Subject: [PATCH 360/470] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/notice/NoticeImage.kt | 22 ------------------- .../repository/NoticeImageRepository.kt | 6 ----- 2 files changed, 28 deletions(-) delete mode 100644 src/main/kotlin/onku/backend/domain/notice/NoticeImage.kt delete mode 100644 src/main/kotlin/onku/backend/domain/notice/repository/NoticeImageRepository.kt diff --git a/src/main/kotlin/onku/backend/domain/notice/NoticeImage.kt b/src/main/kotlin/onku/backend/domain/notice/NoticeImage.kt deleted file mode 100644 index afcce6f..0000000 --- a/src/main/kotlin/onku/backend/domain/notice/NoticeImage.kt +++ /dev/null @@ -1,22 +0,0 @@ -package onku.backend.domain.notice - -import jakarta.persistence.* - -@Entity -@Table(name = "notice_image") -class NoticeImage( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "notice_image_id") - val id: Long? = null, - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "notice_id", nullable = false) - val notice: Notice, - - @Column(name = "image_url", nullable = false, length = 1024) - val imageUrl: String, - - @Column(name = "sort_order", nullable = false) - val sortOrder: Int = 0 -) diff --git a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeImageRepository.kt b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeImageRepository.kt deleted file mode 100644 index 49e6bb1..0000000 --- a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeImageRepository.kt +++ /dev/null @@ -1,6 +0,0 @@ -package onku.backend.domain.notice.repository - -import onku.backend.domain.notice.NoticeImage -import org.springframework.data.jpa.repository.JpaRepository - -interface NoticeImageRepository : JpaRepository From 0bca39707513716ba74f39d90863276ab07e8893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 14 Nov 2025 17:44:04 +0900 Subject: [PATCH 361/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EA=B4=80=EB=A0=A8=20request,=20response=EC=9A=A9?= =?UTF-8?q?=20dto=20=EC=B6=94=EA=B0=80=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/dto/notice/CategoryBadge.kt | 8 ++++++++ .../notice/dto/notice/NoticeCreateRequest.kt | 9 +++++++++ .../notice/dto/notice/NoticeDetailResponse.kt | 13 +++++++++++++ .../notice/dto/notice/NoticeFileWithUrl.kt | 6 ++++++ .../notice/dto/notice/NoticeListItemResponse.kt | 16 ++++++++++++++++ .../notice/dto/notice/NoticeListResponse.kt | 6 ++++++ .../notice/dto/notice/PresignedUploadResponse.kt | 6 ++++++ 7 files changed, 64 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/notice/CategoryBadge.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeCreateRequest.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeDetailResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeFileWithUrl.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListItemResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListResponse.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/notice/PresignedUploadResponse.kt 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..d3a87ba --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/CategoryBadge.kt @@ -0,0 +1,8 @@ +package onku.backend.domain.notice.dto.notice + +import onku.backend.domain.notice.enums.NoticeCategoryColor + +data class CategoryBadge( + val name: String, + 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..c58d3d2 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeFileWithUrl.kt @@ -0,0 +1,6 @@ +package onku.backend.domain.notice.dto.notice + +data class NoticeFileWithUrl ( + val id: Long, + val url: 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..33018c1 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListItemResponse.kt @@ -0,0 +1,16 @@ +package onku.backend.domain.notice.dto.notice + +import onku.backend.domain.notice.enums.NoticeStatus + +data class NoticeListItemResponse( + val id: Long, + val title: String?, + val content: String?, + val authorId: Long, + val authorName: String?, + val categories: List, + val createdAt: String, + val status: NoticeStatus?, + val imageUrls: List, + val fileUrls: List +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListResponse.kt b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListResponse.kt new file mode 100644 index 0000000..79923f3 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListResponse.kt @@ -0,0 +1,6 @@ +package onku.backend.domain.notice.dto.notice + +data class NoticeListResponse( + val totalCount: Long, + val items: 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..a3f3443 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/PresignedUploadResponse.kt @@ -0,0 +1,6 @@ +package onku.backend.domain.notice.dto.notice + +data class PresignedUploadResponse( + val fileId: Long, + val presignedUrl: String +) \ No newline at end of file From 9b965db34a3eda32bc541360e1afa4317fc4a490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 14 Nov 2025 17:44:25 +0900 Subject: [PATCH 362/470] =?UTF-8?q?feat:=20dto=20mapper=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/util/NoticeDtoMapper.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/util/NoticeDtoMapper.kt 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..b3d743a --- /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 From 2a5edf3ca78660546cbd5a00ce8ac6c65ac8f5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 14 Nov 2025 17:45:32 +0900 Subject: [PATCH 363/470] =?UTF-8?q?feat:=20S3=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=AA=85=20=EC=83=81=EC=88=98=EC=97=90=20NOTICE=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt index 1a92566..25fa33e 100644 --- a/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt +++ b/src/main/kotlin/onku/backend/global/s3/enums/FolderName.kt @@ -5,5 +5,6 @@ enum class FolderName{ KUPICK_VIEW, ABSENCE, SESSION, - MEMBER_PROFILE + MEMBER_PROFILE, + NOTICE } \ No newline at end of file From 145fb1f3a516ac6f807933bbe6885475b89ef32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 14 Nov 2025 17:47:27 +0900 Subject: [PATCH 364/470] =?UTF-8?q?fix:=20=EA=B3=B5=EC=A7=80=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=B0=A9=EC=8B=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/notice/repository/NoticeRepository.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt index 63943e8..3186ed3 100644 --- a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt +++ b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt @@ -1,6 +1,11 @@ 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 -interface NoticeRepository : JpaRepository \ No newline at end of file +interface NoticeRepository : JpaRepository { + + @EntityGraph(attributePaths = ["categories", "files"]) + fun findAllByOrderByPublishedAtDescIdDesc(): List +} \ No newline at end of file From e32dfb1a51a05ea74177a7beef869b7263968a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 14 Nov 2025 17:48:32 +0900 Subject: [PATCH 365/470] =?UTF-8?q?feat:=20=EC=B2=A8=EB=B6=80=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20repository=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/repository/NoticeAttachmentRepository.kt | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/repository/NoticeAttachmentRepository.kt 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 From 0f14b27ab0a6391fbb2f155b9273cf9875ba4961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 14 Nov 2025 17:48:59 +0900 Subject: [PATCH 366/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20service=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/service/NoticeAttachmentService.kt | 48 ++++++ .../domain/notice/service/NoticeService.kt | 147 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/service/NoticeAttachmentService.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt 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..e7d8a52 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeAttachmentService.kt @@ -0,0 +1,48 @@ +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 + ): 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 + ) + ) + + return PresignedUploadResponse( + fileId = file.id!!, + presignedUrl = put.preSignedUrl + ) + } +} 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..8f372e4 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt @@ -0,0 +1,147 @@ +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.s3.enums.UploadOption +import onku.backend.global.s3.service.S3Service +import java.time.LocalDateTime + +@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): NoticeListResponse { + val notices = noticeRepository.findAllByOrderByPublishedAtDescIdDesc() + val memberId = currentMember.id!! + + val items = notices.map { n -> + val (imageFiles, fileFiles) = splitPresignedUrls(memberId, n.attachments) + NoticeDtoMapper.toListItem(n, imageFiles, fileFiles) + } + return NoticeListResponse( + totalCount = notices.size.toLong(), + items = items + ) + } + + 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 -> + NoticeFileWithUrl( + id = file.id!!, + url = presignGet(memberId, file.s3Key).preSignedUrl + ) + } + + val fileDtos = otherFiles.map { file -> + NoticeFileWithUrl( + id = file.id!!, + url = presignGet(memberId, file.s3Key).preSignedUrl + ) + } + + return imageDtos to fileDtos + } +} \ No newline at end of file From 64185a65ba76f1430af2267c4bafabaecb02ad71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 14 Nov 2025 17:49:27 +0900 Subject: [PATCH 367/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EA=B4=80=EB=A0=A8=20controller=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/NoticeAttachmentController.kt | 36 ++++++++ .../notice/controller/NoticeController.kt | 86 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/notice/controller/NoticeAttachmentController.kt create mode 100644 src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeAttachmentController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeAttachmentController.kt new file mode 100644 index 0000000..ec959c6 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeAttachmentController.kt @@ -0,0 +1,36 @@ +package onku.backend.domain.notice.controller + +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.PresignedUploadResponse +import onku.backend.domain.notice.service.NoticeAttachmentService +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.response.SuccessResponse +import onku.backend.global.s3.enums.UploadOption + +@RestController +@RequestMapping("/api/v1/notice/files") +@Tag( + name = "[STAFF] 공지 파일 업로드 API", + description = "공지 파일 업로드 관련 API" +) +class NoticeAttachmentController( + private val noticeAttachmentService: NoticeAttachmentService +) { + @PostMapping + @Operation( + summary = "공지 이미지/파일 업로드 URL 발급 [운영진]", + description = "filename을 받아 presigned PUT url 발급" + ) + fun prepareUpload( + @RequestParam filename: String, + @RequestParam fileType: UploadOption, + @CurrentMember member: Member + ): ResponseEntity> { + val body = noticeAttachmentService.prepareUpload(member, filename, fileType) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } +} diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt new file mode 100644 index 0000000..646cfeb --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt @@ -0,0 +1,86 @@ +package onku.backend.domain.notice.controller + +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.NoticeListResponse +import onku.backend.domain.notice.dto.notice.NoticeUpdateRequest +import onku.backend.domain.notice.service.NoticeService +import onku.backend.global.annotation.CurrentMember +import onku.backend.global.response.SuccessResponse + +@RestController +@RequestMapping("/api/v1/notice") +@Tag( + name = "[STAFF] 공지 API", + description = "공지 관련 API" +) +class NoticeController( + private val noticeService: NoticeService +) { + + @GetMapping + @Operation( + summary = "공지 리스트 조회 [운영진]", + description = "전체 개수 + [id, 제목, 작성자id, 작성자이름, {카테고리 이름/색}, 작성일(YYYY/MM/DD HH:MM), 상태, 이미지, 파일" + ) + fun list(@CurrentMember member: Member): ResponseEntity> { + val body = noticeService.list(member) + 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)) + } + + @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)) + } + + @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 From 27e17d3478f83e80b9466bf0c5faa525c13c2723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 02:59:40 +0900 Subject: [PATCH 368/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=EC=9C=BC=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/controller/NoticeController.kt | 17 +++++++----- .../notice/repository/NoticeRepository.kt | 6 +++-- .../domain/notice/service/NoticeService.kt | 27 ++++++++++++++----- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt index 646cfeb..1d6f369 100644 --- a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt +++ b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt @@ -6,12 +6,10 @@ 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.NoticeListResponse -import onku.backend.domain.notice.dto.notice.NoticeUpdateRequest +import onku.backend.domain.notice.dto.notice.* 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 @@ -27,10 +25,15 @@ class NoticeController( @GetMapping @Operation( summary = "공지 리스트 조회 [운영진]", - description = "전체 개수 + [id, 제목, 작성자id, 작성자이름, {카테고리 이름/색}, 작성일(YYYY/MM/DD HH:MM), 상태, 이미지, 파일" + description = "id, 제목, 작성자id, 작성자이름, {카테고리 이름/색}, 작성일(YYYY/MM/DD HH:MM), 상태, 이미지, 파일을 페이징하여 반환" ) - fun list(@CurrentMember member: Member): ResponseEntity> { - val body = noticeService.list(member) + fun list( + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int, + @CurrentMember member: Member + ): ResponseEntity>> { + val safePage = if (page < 1) 0 else page - 1 + val body = noticeService.list(member, safePage, size) return ResponseEntity.ok(SuccessResponse.ok(body)) } diff --git a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt index 3186ed3..c33f8e8 100644 --- a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt +++ b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt @@ -3,9 +3,11 @@ 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", "files"]) - fun findAllByOrderByPublishedAtDescIdDesc(): List + @EntityGraph(attributePaths = ["categories", "attachments"]) + fun findAllByOrderByPublishedAtDescIdDesc(pageable: Pageable): Page } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt index 8f372e4..e09a02f 100644 --- a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt @@ -12,9 +12,12 @@ 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) @@ -25,17 +28,29 @@ class NoticeService( private val s3Service: S3Service ) { - fun list(currentMember: Member): NoticeListResponse { - val notices = noticeRepository.findAllByOrderByPublishedAtDescIdDesc() + fun list(currentMember: Member, page: Int, size: Int): PageResponse { val memberId = currentMember.id!! - val items = notices.map { n -> + val pageable = PageRequest.of( + page, + size, + Sort.by( + Sort.Order.desc("publishedAt"), + Sort.Order.desc("id") + ) + ) + + val noticePage = noticeRepository.findAllByOrderByPublishedAtDescIdDesc(pageable) + + val items = noticePage.content.map { n -> val (imageFiles, fileFiles) = splitPresignedUrls(memberId, n.attachments) NoticeDtoMapper.toListItem(n, imageFiles, fileFiles) } - return NoticeListResponse( - totalCount = notices.size.toLong(), - items = items + + return PageResponse( + data = items, + totalPages = noticePage.totalPages, + isLastPage = noticePage.isLast ) } From be695ee15782300db68208447f63bcc7526a5505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 03:31:37 +0900 Subject: [PATCH 369/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=EB=B3=84=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/controller/NoticeController.kt | 5 +++-- .../notice/repository/NoticeRepository.kt | 6 ++++++ .../domain/notice/service/NoticeService.kt | 18 ++++++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt index 1d6f369..5e95bf0 100644 --- a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt +++ b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt @@ -25,15 +25,16 @@ class NoticeController( @GetMapping @Operation( summary = "공지 리스트 조회 [운영진]", - description = "id, 제목, 작성자id, 작성자이름, {카테고리 이름/색}, 작성일(YYYY/MM/DD HH:MM), 상태, 이미지, 파일을 페이징하여 반환" + 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) + val body = noticeService.list(member, safePage, size, categoryId) return ResponseEntity.ok(SuccessResponse.ok(body)) } diff --git a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt index c33f8e8..2ba3720 100644 --- a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt +++ b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt @@ -10,4 +10,10 @@ interface NoticeRepository : JpaRepository { @EntityGraph(attributePaths = ["categories", "attachments"]) fun findAllByOrderByPublishedAtDescIdDesc(pageable: Pageable): Page + + @EntityGraph(attributePaths = ["categories", "attachments"]) + fun findDistinctByCategories_IdOrderByPublishedAtDescIdDesc( + categoryId: Long, + pageable: Pageable + ): Page } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt index e09a02f..e499ff7 100644 --- a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt @@ -28,7 +28,13 @@ class NoticeService( private val s3Service: S3Service ) { - fun list(currentMember: Member, page: Int, size: Int): PageResponse { + fun list( + currentMember: Member, + page: Int, + size: Int, + categoryId: Long? + ): PageResponse { + val memberId = currentMember.id!! val pageable = PageRequest.of( @@ -40,7 +46,15 @@ class NoticeService( ) ) - val noticePage = noticeRepository.findAllByOrderByPublishedAtDescIdDesc(pageable) + val noticePage = if (categoryId == null) { + noticeRepository.findAllByOrderByPublishedAtDescIdDesc(pageable) + } else { + // 해당 카테고리를 포함하는 공지 검색 + noticeRepository.findDistinctByCategories_IdOrderByPublishedAtDescIdDesc( + categoryId, + pageable + ) + } val items = noticePage.content.map { n -> val (imageFiles, fileFiles) = splitPresignedUrls(memberId, n.attachments) From 994363111b420dd0a0a9cb046bbe6aa7191b9e11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 13:58:55 +0900 Subject: [PATCH 370/470] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20dto=20=EC=B6=94=EA=B0=80=20#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/dto/MemberInfoResponses.kt | 32 +++++++++++++++++++ .../member/dto/MemberProfileUpdateRequest.kt | 11 +++++++ 2 files changed, 43 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/MemberProfileUpdateRequest.kt 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..c00371c --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt @@ -0,0 +1,32 @@ +package onku.backend.domain.member.dto + +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Part +import onku.backend.domain.member.enums.SocialType + +data class MemberInfoListResponse( + val pendingCount: Long, + val approvedCount: Long, + val rejectedCount: Long, + val members: List, +) + +data class MemberItemResponse( + val memberId: Long, + val name: String?, + val profileImageUrl: String?, + val part: Part, + val school: String?, + val major: String?, + val phoneNumber: String?, + val socialType: SocialType, + val email: String?, + val approval: ApprovalStatus, +) + +data class MemberApprovalListResponse( + val pendingCount: Long, + val approvedCount: Long, + val rejectedCount: Long, + val members: List, +) 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..00a1baa --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileUpdateRequest.kt @@ -0,0 +1,11 @@ +package onku.backend.domain.member.dto + +import onku.backend.domain.member.enums.Part + +data class MemberProfileUpdateRequest( + val name: String, + val school: String? = null, + val major: String? = null, + val part: Part, + val phoneNumber: String? = null, +) From 4a97c54b28b2cf96568a25f6b8e495e4f8373acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 13:59:29 +0900 Subject: [PATCH 371/470] =?UTF-8?q?feat:=20memberId=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=EC=9D=84=20body=EC=97=90=EC=84=9C=20param?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=20#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/dto/UpdateRoleRequest.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/UpdateRoleRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/UpdateRoleRequest.kt index 56e29b7..375dd07 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/UpdateRoleRequest.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/UpdateRoleRequest.kt @@ -1,13 +1,10 @@ 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 -import software.amazon.awssdk.annotations.NotNull data class UpdateRoleRequest( - @field:NotNull - @Schema(description = "권한을 변경할 사용자 ID", example = "1") - val memberId: Long?, @field:NotNull @Schema(description = "변경할 권한", example = "STAFF") From a1cb06403851232deb10974af14f471f242e0e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 14:02:52 +0900 Subject: [PATCH 372/470] =?UTF-8?q?feat:=20approval=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EB=B3=84=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=88=98=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=B6=94=EA=B0=80=20#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/repository/MemberRepository.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt index 11fa851..c9db7c7 100644 --- a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt @@ -1,6 +1,7 @@ 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 @@ -14,4 +15,5 @@ interface MemberRepository : JpaRepository { and m.approval = onku.backend.domain.member.enums.ApprovalStatus.APPROVED """) fun findApprovedMemberIds(): List + fun countByApproval(approval: ApprovalStatus): Long } \ No newline at end of file From d1f888512d73da7c61d4503f2e3ae7080d451a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 14:04:14 +0900 Subject: [PATCH 373/470] =?UTF-8?q?feat:=20=EC=8A=B9=EC=9D=B8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=84=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/repository/MemberProfileRepository.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt index cad3585..20458d0 100644 --- a/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt @@ -1,7 +1,7 @@ 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 @@ -19,4 +19,12 @@ interface MemberProfileRepository : JpaRepository { @EntityGraph(attributePaths = ["member"]) fun findAllByOrderByPartAscNameAsc(pageable: Pageable): Page + + // APPROVED + @EntityGraph(attributePaths = ["member"]) + fun findByMember_Approval(approval: ApprovalStatus): List + + // PENDING, REJECTED + @EntityGraph(attributePaths = ["member"]) + fun findByMember_ApprovalIn(approvals: Collection): List } From b89154471f79236dc1b53243f61872f532cb297f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 14:06:18 +0900 Subject: [PATCH 374/470] =?UTF-8?q?feat:=20memberId=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/member/service/MemberService.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt index 7eae73b..a9bb67d 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -75,6 +75,7 @@ class MemberService( memberRepository.deleteById(memberId) } + @Transactional fun updateApproval(memberId: Long, targetStatus: ApprovalStatus): MemberApprovalResponse { if (targetStatus == ApprovalStatus.PENDING) { throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) @@ -90,7 +91,7 @@ class MemberService( when (targetStatus) { ApprovalStatus.APPROVED -> member.approve() ApprovalStatus.REJECTED -> member.reject() - ApprovalStatus.PENDING -> { } + ApprovalStatus.PENDING -> {} } val saved = memberRepository.save(member) @@ -103,14 +104,15 @@ class MemberService( } @Transactional - fun updateRole(actor: Member, req: UpdateRoleRequest): MemberRoleResponse { - val targetMemberId = req.memberId ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) + fun updateRole( + memberId: Long, + req: UpdateRoleRequest + ): MemberRoleResponse { val newRole = req.role ?: throw CustomException(MemberErrorCode.INVALID_REQUEST) - val target = memberRepository.findByIdOrNull(targetMemberId) + val target = memberRepository.findByIdOrNull(memberId) ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) - // TODO: actor.role에 따른 변경 허용 범위 검증 로직 복구/추가 target.role = newRole memberRepository.save(target) @@ -119,4 +121,4 @@ class MemberService( role = target.role ) } -} +} \ No newline at end of file From f8e5af7c1113a61cf446082a136e457415a1531d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 14:07:27 +0900 Subject: [PATCH 375/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95,=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EC=83=81=ED=83=9C=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=98=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/service/MemberProfileService.kt | 104 +++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index 14b4c80..e917383 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -1,8 +1,8 @@ package onku.backend.domain.member.service import onku.backend.domain.member.Member -import onku.backend.domain.member.MemberProfile 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 @@ -92,7 +92,6 @@ class MemberProfileService( ) } - @Transactional(readOnly = true) fun getProfileBasics(member: Member): MemberProfileBasicsResponse { val profile = memberProfileRepository.findById(member.id!!) @@ -140,4 +139,105 @@ class MemberProfileService( 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 getApprovedMemberInfos(): MemberInfoListResponse { + val pendingCount = memberRepository.countByApproval(ApprovalStatus.PENDING) + val approvedCount = memberRepository.countByApproval(ApprovalStatus.APPROVED) + val rejectedCount = memberRepository.countByApproval(ApprovalStatus.REJECTED) + + val profiles = memberProfileRepository.findByMember_Approval(ApprovalStatus.APPROVED) + + val members = profiles.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, + approval = member.approval + ) + } + + return MemberInfoListResponse( + pendingCount = pendingCount, + approvedCount = approvedCount, + rejectedCount = rejectedCount, + members = members + ) + } + + @Transactional(readOnly = true) + fun getApprovalRequestMembers(): MemberApprovalListResponse { + val pendingCount = memberRepository.countByApproval(ApprovalStatus.PENDING) + val approvedCount = memberRepository.countByApproval(ApprovalStatus.APPROVED) + val rejectedCount = memberRepository.countByApproval(ApprovalStatus.REJECTED) + + val profiles = memberProfileRepository.findByMember_ApprovalIn( + listOf(ApprovalStatus.PENDING, ApprovalStatus.REJECTED) + ) + + val members = profiles.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, + approval = member.approval + ) + } + + return MemberApprovalListResponse( + pendingCount = pendingCount, + approvedCount = approvedCount, + rejectedCount = rejectedCount, + members = members + ) + } } \ No newline at end of file From 39f9a1e6a29a155b2904275bd3fb0e4985c33373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 14:08:04 +0900 Subject: [PATCH 376/470] =?UTF-8?q?feat:=20=ED=95=99=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20API=20=EC=B6=94=EA=B0=80=20#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.kt | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt index 27c74fa..620d5e6 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt @@ -3,8 +3,6 @@ package onku.backend.domain.member.controller 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.MemberApprovalResponse -import onku.backend.domain.member.dto.UpdateApprovalRequest import onku.backend.domain.member.Member import onku.backend.domain.member.dto.* import onku.backend.domain.member.service.MemberProfileService @@ -77,16 +75,51 @@ class MemberController( return ResponseEntity.ok(SuccessResponse.ok(result)) } + @PatchMapping("/{memberId}/role") @Operation( - summary = "[QA] 사용자 권한(role) 수정", - description = "요청 본문에 수정하고자 하는 memberId, role을 담아 권한을 수정합니다." + summary = "[EXECUTIVE] 사용자 권한 수정", + description = "URL 의 memberId 에 해당하는 사용자의 권한을 role 로 수정합니다." ) - @PatchMapping("/role") fun updateRole( - @CurrentMember actor: Member, + @PathVariable memberId: Long, @RequestBody @Valid req: UpdateRoleRequest ): ResponseEntity> { - val body = memberService.updateRole(actor, req) + val body = memberService.updateRole(memberId, req) return ResponseEntity.ok(SuccessResponse.ok(body)) } + + @PatchMapping("/{memberId}/profile") + @Operation( + summary = "[STAFF] 학회원 프로필 정보 수정", + description = "관리자가 특정 학회원의 [이름, 학교, 학과, 전화번호, 파트]를 수정합니다." + ) + fun updateMemberProfile( + @PathVariable memberId: Long, + @RequestBody @Valid req: MemberProfileUpdateRequest + ): SuccessResponse { + val body = memberProfileService.updateProfile(memberId, req) + return SuccessResponse.ok(body) + } + + @GetMapping("/approvals") + @Operation( + summary = "[STAFF] 학회원 정보 목록 조회 (APPROVED)", + description = "PENDING/APPROVED/REJECTED 수와 함께, APPROVED 상태인 학회원들만 목록으로 반환합니다." + ) + fun getApprovedMembers( + ): SuccessResponse { + val body = memberProfileService.getApprovedMemberInfos() + return SuccessResponse.ok(body) + } + + @GetMapping("/requests") + @Operation( + summary = "[STAFF] 승인 요청 목록 조회 (PENDING/REJECTED)", + description = "PENDING/APPROVED/REJECTED 수와 함께, PENDING 및 REJECTED 상태인 학회원들만 목록으로 반환합니다." + ) + fun getApprovalRequests( + ): SuccessResponse { + val body = memberProfileService.getApprovalRequestMembers() + return SuccessResponse.ok(body) + } } \ No newline at end of file From 62364e58caf6690b53298e413489fdee0ef6d180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 14:08:25 +0900 Subject: [PATCH 377/470] =?UTF-8?q?feat:=20=ED=95=99=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B6=8C=ED=95=9C=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80=20#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/auth/config/SecurityConfig.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index eec26cc..33b6548 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -38,7 +38,10 @@ class SecurityConfig( private val STAFF_ENDPOINT = arrayOf( // 운영진 "/api/v1/session/staff/**", "/api/v1/members/*/approval", - "/api/v1/notice/categories/**" + "/api/v1/notice/categories/**", + "/api/v1/members/*/profile", + "/api/v1/members/approvals", + "/api/v1/members/requests", ) private val MANAGEMENT_ENDPOINT = arrayOf( // 경총 @@ -48,7 +51,9 @@ class SecurityConfig( "/api/v1/absence/manage/**" ) -// private val EXECUTIVE = arrayOf("") // 회장단 + private val EXECUTIVE = arrayOf( // 회장단 + "/api/v1/members/*/role", + ) } @Bean @@ -69,7 +74,7 @@ class SecurityConfig( .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) + .requestMatchers(*EXECUTIVE).hasAnyRole(Role.EXECUTIVE.name) .anyRequest().hasRole(Role.USER.name) } .addFilterBefore(JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter::class.java) From 45629aedf0c61d4bceba411d49c51f0050f534ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 14:45:09 +0900 Subject: [PATCH 378/470] =?UTF-8?q?refactor:=20dto=20swagger=20description?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/dto/notice/CategoryBadge.kt | 6 ++++ .../dto/notice/NoticeListItemResponse.kt | 29 +++++++++++++++++++ .../notice/dto/notice/NoticeListResponse.kt | 6 ---- .../dto/notice/PresignedUploadResponse.kt | 10 +++++++ 4 files changed, 45 insertions(+), 6 deletions(-) delete mode 100644 src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListResponse.kt 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 index d3a87ba..174d426 100644 --- a/src/main/kotlin/onku/backend/domain/notice/dto/notice/CategoryBadge.kt +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/CategoryBadge.kt @@ -1,8 +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/NoticeListItemResponse.kt b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListItemResponse.kt index 33018c1..76b94b2 100644 --- a/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListItemResponse.kt +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListItemResponse.kt @@ -1,16 +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/NoticeListResponse.kt b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListResponse.kt deleted file mode 100644 index 79923f3..0000000 --- a/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeListResponse.kt +++ /dev/null @@ -1,6 +0,0 @@ -package onku.backend.domain.notice.dto.notice - -data class NoticeListResponse( - val totalCount: Long, - val items: 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 index a3f3443..06c3222 100644 --- a/src/main/kotlin/onku/backend/domain/notice/dto/notice/PresignedUploadResponse.kt +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/PresignedUploadResponse.kt @@ -1,6 +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 From 2bf3b6863bf3c90eec4d3c853a04e0e0e03dc42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 14:47:19 +0900 Subject: [PATCH 379/470] =?UTF-8?q?refactor:=20=ED=95=A8=EC=88=98=EB=AA=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/notice/repository/NoticeRepository.kt | 2 +- .../kotlin/onku/backend/domain/notice/service/NoticeService.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt index 2ba3720..794c042 100644 --- a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt +++ b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt @@ -12,7 +12,7 @@ interface NoticeRepository : JpaRepository { fun findAllByOrderByPublishedAtDescIdDesc(pageable: Pageable): Page @EntityGraph(attributePaths = ["categories", "attachments"]) - fun findDistinctByCategories_IdOrderByPublishedAtDescIdDesc( + fun findDistinctByCategoriesIdOrderByPublishedAtDescIdDesc( categoryId: Long, pageable: Pageable ): Page diff --git a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt index e499ff7..9ea60d7 100644 --- a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt @@ -50,7 +50,7 @@ class NoticeService( noticeRepository.findAllByOrderByPublishedAtDescIdDesc(pageable) } else { // 해당 카테고리를 포함하는 공지 검색 - noticeRepository.findDistinctByCategories_IdOrderByPublishedAtDescIdDesc( + noticeRepository.findDistinctByCategoriesIdOrderByPublishedAtDescIdDesc( categoryId, pageable ) From 98bb486ab90c3148e8acc6d78b8662246631b67f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 14:58:12 +0900 Subject: [PATCH 380/470] =?UTF-8?q?feat:=20dto=20swagger=20description=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/dto/MemberInfoResponses.kt | 42 ++++++++++++++++++- .../member/dto/MemberProfileUpdateRequest.kt | 14 ++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt index c00371c..aaba126 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt @@ -1,32 +1,72 @@ package onku.backend.domain.member.dto +import io.swagger.v3.oas.annotations.media.Schema import onku.backend.domain.member.enums.ApprovalStatus import onku.backend.domain.member.enums.Part import onku.backend.domain.member.enums.SocialType +@Schema(description = "회원 승인/가입 현황 리스트 응답") data class MemberInfoListResponse( + + @Schema(description = "승인 대기 중인 회원 수", example = "5") val pendingCount: Long, + + @Schema(description = "승인 완료된 회원 수", example = "40") val approvedCount: Long, + + @Schema(description = "승인 거절된 회원 수", example = "3") val rejectedCount: Long, + + @Schema(description = "회원 정보 리스트") val members: List, ) +@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 = "PENDING") val approval: ApprovalStatus, ) +@Schema(description = "운영진용 회원 승인 현황 리스트 응답") data class MemberApprovalListResponse( + + @Schema(description = "승인 대기 중인 회원 수", example = "5") val pendingCount: Long, + + @Schema(description = "승인 완료된 회원 수", example = "40") val approvedCount: Long, + + @Schema(description = "승인 거절된 회원 수", example = "3") val rejectedCount: Long, + + @Schema(description = "회원 승인 정보 리스트") val members: List, -) +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileUpdateRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileUpdateRequest.kt index 00a1baa..fccb283 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileUpdateRequest.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileUpdateRequest.kt @@ -1,11 +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 From 062024f8b9fc7b8aa8acee4a4093380d4529006c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 15:01:02 +0900 Subject: [PATCH 381/470] =?UTF-8?q?refactor:=20=ED=95=A8=EC=88=98=EB=AA=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/repository/MemberProfileRepository.kt | 4 ++-- .../backend/domain/member/service/MemberProfileService.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt index 20458d0..1e7a5fa 100644 --- a/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt @@ -22,9 +22,9 @@ interface MemberProfileRepository : JpaRepository { // APPROVED @EntityGraph(attributePaths = ["member"]) - fun findByMember_Approval(approval: ApprovalStatus): List + fun findByMemberApproval(approval: ApprovalStatus): List // PENDING, REJECTED @EntityGraph(attributePaths = ["member"]) - fun findByMember_ApprovalIn(approvals: Collection): List + fun findByMemberApprovalIn(approvals: Collection): List } diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index e917383..d72c919 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -175,7 +175,7 @@ class MemberProfileService( val approvedCount = memberRepository.countByApproval(ApprovalStatus.APPROVED) val rejectedCount = memberRepository.countByApproval(ApprovalStatus.REJECTED) - val profiles = memberProfileRepository.findByMember_Approval(ApprovalStatus.APPROVED) + val profiles = memberProfileRepository.findByMemberApproval(ApprovalStatus.APPROVED) val members = profiles.map { profile -> val member = profile.member @@ -210,7 +210,7 @@ class MemberProfileService( val approvedCount = memberRepository.countByApproval(ApprovalStatus.APPROVED) val rejectedCount = memberRepository.countByApproval(ApprovalStatus.REJECTED) - val profiles = memberProfileRepository.findByMember_ApprovalIn( + val profiles = memberProfileRepository.findByMemberApprovalIn( listOf(ApprovalStatus.PENDING, ApprovalStatus.REJECTED) ) From fe62c18cfc536fe802672c3a119a6f25b4bb7880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 15:01:59 +0900 Subject: [PATCH 382/470] =?UTF-8?q?feat:=20=EC=95=88=20=EC=93=B0=EB=8A=94?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=20=EC=82=AD=EC=A0=9C=20#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/service/MemberService.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt index a9bb67d..c27cd48 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -28,10 +28,6 @@ class MemberService( fun getBySocialIdOrNull(socialId: Long, socialType: SocialType): Member? = memberRepository.findBySocialIdAndSocialType(socialId, socialType) - fun getBySocialId(socialId: Long, socialType: SocialType): Member = - getBySocialIdOrNull(socialId, socialType) - ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) - @Transactional fun upsertSocialMember(email: String?, socialId: Long, type: SocialType): Member { val existing = memberRepository.findBySocialIdAndSocialType(socialId, type) From fc04dc083112ed109f8e2d3f2adb7c54497743c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 15:07:32 +0900 Subject: [PATCH 383/470] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EC=A4=91?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=95=8A=EC=9D=80=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20#126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/member/service/MemberService.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt index c27cd48..89996bf 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -25,9 +25,6 @@ class MemberService( memberRepository.findByEmail(email) ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) - fun getBySocialIdOrNull(socialId: Long, socialType: SocialType): Member? = - memberRepository.findBySocialIdAndSocialType(socialId, socialType) - @Transactional fun upsertSocialMember(email: String?, socialId: Long, type: SocialType): Member { val existing = memberRepository.findBySocialIdAndSocialType(socialId, type) From fe983fdae3727fa9d6d5b450f300bdf18b787c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 20:33:06 +0900 Subject: [PATCH 384/470] =?UTF-8?q?feat:=20=EC=A0=9C=EB=AA=A9=20or=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=EC=97=90=EC=84=9C=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EA=B2=80=EC=83=89=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20#128?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/notice/repository/NoticeRepository.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt index 794c042..dbb9b99 100644 --- a/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt +++ b/src/main/kotlin/onku/backend/domain/notice/repository/NoticeRepository.kt @@ -16,4 +16,11 @@ interface NoticeRepository : JpaRepository { categoryId: Long, pageable: Pageable ): Page + + @EntityGraph(attributePaths = ["categories", "attachments"]) + fun findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseOrderByPublishedAtDescIdDesc( + titleKeyword: String, + contentKeyword: String, + pageable: Pageable + ): Page } \ No newline at end of file From 089e3a41b39b9e8ad86ee02896578e97833e4f37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 20:34:05 +0900 Subject: [PATCH 385/470] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80=20#128?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/service/NoticeService.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt index 9ea60d7..cc27f1e 100644 --- a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt @@ -68,6 +68,42 @@ class NoticeService( ) } + 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 items = noticePage.content.map { n -> + val (imageFiles, fileFiles) = splitPresignedUrls(memberId, n.attachments) + NoticeDtoMapper.toListItem(n, imageFiles, fileFiles) + } + return PageResponse( + data = items, + totalPages = noticePage.totalPages, + isLastPage = noticePage.isLast + ) + } + fun get(noticeId: Long, currentMember: Member): NoticeDetailResponse { val n = noticeRepository.findById(noticeId) .orElseThrow { CustomException(NoticeErrorCode.NOTICE_NOT_FOUND) } From 3f95ad3d49496ff37dd5252fc667581a1394b592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 20:34:20 +0900 Subject: [PATCH 386/470] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#128?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/controller/NoticeController.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt index 5e95bf0..33aedb9 100644 --- a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt +++ b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt @@ -87,4 +87,19 @@ class NoticeController( noticeService.delete(noticeId) return ResponseEntity.ok(SuccessResponse.ok(Unit)) } + + @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)) + } } \ No newline at end of file From 7ab61f07f4c13d37b35659bd421e53659d79b9a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 15 Nov 2025 20:53:00 +0900 Subject: [PATCH 387/470] =?UTF-8?q?feat:=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EA=B6=8C=ED=95=9C=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20#128?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/notice/controller/NoticeController.kt | 2 +- .../onku/backend/global/auth/config/SecurityConfig.kt | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt index 33aedb9..38acbc0 100644 --- a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt +++ b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt @@ -90,7 +90,7 @@ class NoticeController( @GetMapping("/search") @Operation( - summary = "공지 검색 [운영진]", + summary = "공지 검색 [학회원]", description = "검색어로 공지 제목/내용에서 검색하여 페이징 반환합니다." ) fun search( diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index 33b6548..31db572 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -35,10 +35,14 @@ class SecurityConfig( "/api/v1/members/profile/image/url" ) + private val USER_ENDPOINT = arrayOf( // TODO: 컨트롤러 권한별 분리로 보다 더 명시적이게 수정 + "/api/v1/notice/search" + ) + private val STAFF_ENDPOINT = arrayOf( // 운영진 "/api/v1/session/staff/**", "/api/v1/members/*/approval", - "/api/v1/notice/categories/**", + "/api/v1/notice/**", "/api/v1/members/*/profile", "/api/v1/members/approvals", "/api/v1/members/requests", @@ -72,6 +76,7 @@ class SecurityConfig( // 권한 별 엔드포인트 .requestMatchers(*ONBOARDING_ENDPOINT).hasRole(Role.GUEST.name) + .requestMatchers(*USER_ENDPOINT).hasRole(Role.USER.name) .requestMatchers(*STAFF_ENDPOINT).hasRole(Role.STAFF.name) .requestMatchers(*MANAGEMENT_ENDPOINT).hasRole(Role.MANAGEMENT.name) .requestMatchers(*EXECUTIVE).hasAnyRole(Role.EXECUTIVE.name) From d39d49ec0f38f1fb00a3ea5b84a11ec4c4387236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 17 Nov 2025 21:13:44 +0900 Subject: [PATCH 388/470] =?UTF-8?q?feat:=20=EC=8B=9C=EC=9E=91=EC=9D=BC?= =?UTF-8?q?=EC=9E=90=EB=A1=9C=20=EC=84=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#131?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/session/repository/SessionRepository.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt index eb914dc..2de5ede 100644 --- a/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt +++ b/src/main/kotlin/onku/backend/domain/session/repository/SessionRepository.kt @@ -144,4 +144,9 @@ interface SessionRepository : CrudRepository { """ ) fun findByDetailIdFetchDetail(@Param("detailId") detailId: Long): Session? + + fun findByStartDateBetween( + startDate: LocalDate, + endDate: LocalDate + ): List } From 924b434c3d13428a74629066da218e4c90f17cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 17 Nov 2025 21:20:00 +0900 Subject: [PATCH 389/470] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20Attendance=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B3=84=EC=82=B0=ED=95=B4=EC=98=A4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#131?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/point/service/AdminPointService.kt | 130 ++++++++++-------- 1 file changed, 74 insertions(+), 56 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt b/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt index d370ffa..25e50be 100644 --- a/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt +++ b/src/main/kotlin/onku/backend/domain/point/service/AdminPointService.kt @@ -13,10 +13,13 @@ 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 @@ -148,48 +151,58 @@ class AdminPointService( page: Int, size: Int ): MonthlyAttendancePageResponse { - require(month in 8..12) { "month must be 8..12" } - - // 조회 구간 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 startParts = sessionRepository.findStartDateAndTimeBetweenDates(startDate, endDateInclusive) + val sessionsInMonth: List = + sessionRepository.findByStartDateBetween(startDate, endDateInclusive) - val sessionStartDateTimes: List = startParts - .map { LocalDateTime.of(it.getStartDate(), it.getStartTime()) } - .filter { dt -> !dt.isBefore(start) && dt.isBefore(end) } + val sessionDateById: Map = sessionsInMonth + .associate { session -> session.id!! to session.startDate } - val sessionDates: List = sessionStartDateTimes - .map { it.toLocalDate() } + 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 historyRecords = memberPointHistoryRepository.findAttendanceByMemberIdsBetween( - memberIds = pageMemberIds, - category = PointCategory.ATTENDANCE, - start = start, - end = end - ) + if (pageMemberIds.isEmpty()) { + throw CustomException(MemberErrorCode.PAGE_MEMBERS_NOT_FOUND) + } val attendanceList = attendanceRepository.findByMemberIdInAndAttendanceTimeBetween( - pageMemberIds, start, end + pageMemberIds, + start, + end ) val attendanceIdByMemberDate: Map, Long> = attendanceList.associateBy( - { it.memberId to it.attendanceTime.toLocalDate() }, + { attendance -> + val sessionDate: LocalDate = + sessionDateById[attendance.sessionId] ?: attendance.attendanceTime.toLocalDate() + attendance.memberId to sessionDate + }, { it.id!! } ) @@ -198,51 +211,56 @@ class AdminPointService( val date: LocalDate, val attendanceId: Long?, val status: AttendancePointType, - val point: Int + val point: Int, ) - val rows: List = historyRecords.map { r -> - val memberId = r.member.id!! - val date = r.occurredAt.toLocalDate() - val attId = attendanceIdByMemberDate[memberId to date] + val rows: List = attendanceList.map { attendance -> + val date: LocalDate = + sessionDateById[attendance.sessionId] ?: attendance.attendanceTime.toLocalDate() + Row( - memberId = memberId, + memberId = attendance.memberId, date = date, - attendanceId = attId, - status = AttendancePointType.valueOf(r.type), - point = r.points + attendanceId = attendance.id!!, + status = attendance.status, + point = attendance.status.points ) } - val rowsByMember = rows.groupBy { it.memberId } + val rowsByMember: Map> = rows.groupBy { it.memberId } - val memberDtos = memberPage.content.map { profile -> + val memberDtos: List = memberPage.content.map { profile -> val memberId = profile.memberId!! - val baseRecords = rowsByMember[memberId] - ?.sortedBy { it.date } - ?.map { - AttendanceRecordDto( - date = it.date, - attendanceId = it.attendanceId, - status = it.status, - point = it.point - ) - } - ?.toMutableList() - ?: mutableListOf() - if (sessionDates.isNotEmpty()) { - val recordedDates = baseRecords.map { it.date }.toSet() - sessionDates.filter { it !in recordedDates }.forEach { date -> - baseRecords.add( + val baseRecords: MutableList = + rowsByMember[memberId] + ?.sortedBy { it.date } + ?.map { row -> AttendanceRecordDto( - date = date, - attendanceId = attendanceIdByMemberDate[memberId to date], - status = null, - point = null + 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 } } @@ -253,8 +271,8 @@ class AdminPointService( ) } - val dtoPage = memberPage.map { p -> - memberDtos.first { it.memberId == p.memberId } + val dtoPage = memberPage.map { profile -> + memberDtos.first { it.memberId == profile.memberId } } return MonthlyAttendancePageResponse( From d0cb0f43cbb8e07157303aff1f4c0aec9705cf4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 17 Nov 2025 21:20:19 +0900 Subject: [PATCH 390/470] =?UTF-8?q?feat:=208=EC=9B=94=EB=B6=80=ED=84=B0=20?= =?UTF-8?q?12=EC=9B=94=EA=B9=8C=EC=A7=80=EC=9D=98=20=EC=9B=94=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=EC=97=90=EC=84=9C=20=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20#131?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/controller/manager/PointManagerController.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index fdef16e..f3606b1 100644 --- a/src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/point/controller/manager/PointManagerController.kt @@ -3,12 +3,15 @@ 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 @@ -18,6 +21,7 @@ import java.time.YearMonth name = "[MANAGEMENT] 운영진 상벌점", description = "운영진 상벌점 대시보드 조회 API" ) +@Validated class PointManagerController( private val adminPointsService: AdminPointService, private val commandService: AdminPointCommandService @@ -101,7 +105,7 @@ class PointManagerController( description = "year, month, page, size를 받아 멤버별 [date, attendanceId, status, point] 목록을 페이징으로 반환" ) fun getMonthly( - @RequestParam month: Int, + @RequestParam @Min(8) @Max(12) month: Int, @RequestParam(defaultValue = "1") page: Int, @RequestParam(defaultValue = "10") size: Int, ): ResponseEntity> { @@ -114,7 +118,7 @@ class PointManagerController( @PatchMapping("/monthly") @Operation( summary = "월별 출석 상태 수정 [운영진]", - description = "attendanceId, memberId, status를 받아 출석 상태를 수정하고, 상벌점 변동을 MemberPointHistory에 기록합니다." + description = "attendanceId, memberId, status를 받아 출석 상태를 수정하고, 상벌점 변동을 기록합니다." ) fun updateMonthly( @RequestBody @Valid req: UpdateAttendanceStatusRequest From fcaf0f05fa6edf9aa0a60fe6b7389f5bebaf59d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 17 Nov 2025 21:40:08 +0900 Subject: [PATCH 391/470] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EA=B6=8C=ED=95=9C=20=EB=B6=84=EA=B8=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/NoticeAttachmentController.kt | 36 --------- .../{ => manager}/NoticeCategoryController.kt | 2 +- .../manager/NoticeManagerController.kt | 80 +++++++++++++++++++ .../controller/{ => user}/NoticeController.kt | 55 +++---------- .../global/auth/config/SecurityConfig.kt | 7 +- 5 files changed, 91 insertions(+), 89 deletions(-) delete mode 100644 src/main/kotlin/onku/backend/domain/notice/controller/NoticeAttachmentController.kt rename src/main/kotlin/onku/backend/domain/notice/controller/{ => manager}/NoticeCategoryController.kt (98%) create mode 100644 src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeManagerController.kt rename src/main/kotlin/onku/backend/domain/notice/controller/{ => user}/NoticeController.kt (59%) diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeAttachmentController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/NoticeAttachmentController.kt deleted file mode 100644 index ec959c6..0000000 --- a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeAttachmentController.kt +++ /dev/null @@ -1,36 +0,0 @@ -package onku.backend.domain.notice.controller - -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.PresignedUploadResponse -import onku.backend.domain.notice.service.NoticeAttachmentService -import onku.backend.global.annotation.CurrentMember -import onku.backend.global.response.SuccessResponse -import onku.backend.global.s3.enums.UploadOption - -@RestController -@RequestMapping("/api/v1/notice/files") -@Tag( - name = "[STAFF] 공지 파일 업로드 API", - description = "공지 파일 업로드 관련 API" -) -class NoticeAttachmentController( - private val noticeAttachmentService: NoticeAttachmentService -) { - @PostMapping - @Operation( - summary = "공지 이미지/파일 업로드 URL 발급 [운영진]", - description = "filename을 받아 presigned PUT url 발급" - ) - fun prepareUpload( - @RequestParam filename: String, - @RequestParam fileType: UploadOption, - @CurrentMember member: Member - ): ResponseEntity> { - val body = noticeAttachmentService.prepareUpload(member, filename, fileType) - return ResponseEntity.ok(SuccessResponse.ok(body)) - } -} diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeCategoryController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeCategoryController.kt similarity index 98% rename from src/main/kotlin/onku/backend/domain/notice/controller/NoticeCategoryController.kt rename to src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeCategoryController.kt index e1c4881..ff18958 100644 --- a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeCategoryController.kt +++ b/src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeCategoryController.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.notice.controller +package onku.backend.domain.notice.controller.manager import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag 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..f0f8176 --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeManagerController.kt @@ -0,0 +1,80 @@ +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을 받아 presigned PUT url 발급" + ) + fun prepareUpload( + @RequestParam filename: String, + @RequestParam fileType: UploadOption, + @CurrentMember member: Member + ): ResponseEntity> { + val body = noticeAttachmentService.prepareUpload(member, filename, fileType) + 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)) + } +} diff --git a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt b/src/main/kotlin/onku/backend/domain/notice/controller/user/NoticeController.kt similarity index 59% rename from src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt rename to src/main/kotlin/onku/backend/domain/notice/controller/user/NoticeController.kt index 38acbc0..6f2098d 100644 --- a/src/main/kotlin/onku/backend/domain/notice/controller/NoticeController.kt +++ b/src/main/kotlin/onku/backend/domain/notice/controller/user/NoticeController.kt @@ -1,12 +1,12 @@ -package onku.backend.domain.notice.controller +package onku.backend.domain.notice.controller.user 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.* +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 @@ -15,8 +15,8 @@ import onku.backend.global.response.SuccessResponse @RestController @RequestMapping("/api/v1/notice") @Tag( - name = "[STAFF] 공지 API", - description = "공지 관련 API" + name = "[USER/MANAGEMENT] 공지 API", + description = "공지 조회/검색 API" ) class NoticeController( private val noticeService: NoticeService @@ -24,7 +24,7 @@ class NoticeController( @GetMapping @Operation( - summary = "공지 리스트 조회 [운영진]", + summary = "공지 리스트 조회", description = "id, 제목, 작성자id, 작성자이름, {카테고리 이름/색}, 작성일(YYYY/MM/DD HH:MM), 상태, 이미지, 파일을 페이징하여 반환. 전체 조회가 필요한 경우 카테고리 id는 비워두시면 됩니다." ) fun list( @@ -40,7 +40,7 @@ class NoticeController( @GetMapping("/{noticeId}") @Operation( - summary = "공지 단일 조회 [운영진]", + summary = "공지 단일 조회", description = "제목, 카테고리, 작성일자(MM:dd HH:MM), 내용, 작성자id, 작성자이름, 이미지, 파일 presigned url 리스트" ) fun get( @@ -51,46 +51,9 @@ class NoticeController( return ResponseEntity.ok(SuccessResponse.ok(body)) } - @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)) - } - - @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)) - } - @GetMapping("/search") @Operation( - summary = "공지 검색 [학회원]", + summary = "공지 검색", description = "검색어로 공지 제목/내용에서 검색하여 페이징 반환합니다." ) fun search( @@ -102,4 +65,4 @@ class NoticeController( val body = noticeService.search(keyword, safePage, size) return ResponseEntity.ok(SuccessResponse.ok(body)) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index 31db572..df426bf 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -35,17 +35,13 @@ class SecurityConfig( "/api/v1/members/profile/image/url" ) - private val USER_ENDPOINT = arrayOf( // TODO: 컨트롤러 권한별 분리로 보다 더 명시적이게 수정 - "/api/v1/notice/search" - ) - private val STAFF_ENDPOINT = arrayOf( // 운영진 "/api/v1/session/staff/**", "/api/v1/members/*/approval", - "/api/v1/notice/**", "/api/v1/members/*/profile", "/api/v1/members/approvals", "/api/v1/members/requests", + "/api/v1/notice/manage/**", ) private val MANAGEMENT_ENDPOINT = arrayOf( // 경총 @@ -76,7 +72,6 @@ class SecurityConfig( // 권한 별 엔드포인트 .requestMatchers(*ONBOARDING_ENDPOINT).hasRole(Role.GUEST.name) - .requestMatchers(*USER_ENDPOINT).hasRole(Role.USER.name) .requestMatchers(*STAFF_ENDPOINT).hasRole(Role.STAFF.name) .requestMatchers(*MANAGEMENT_ENDPOINT).hasRole(Role.MANAGEMENT.name) .requestMatchers(*EXECUTIVE).hasAnyRole(Role.EXECUTIVE.name) From 8176df96efddb6d2a506af5d532cec86db66a71e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 13:12:10 +0900 Subject: [PATCH 392/470] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=B3=80=EA=B2=BD=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?dto=EB=93=A4=20=EC=B6=94=EA=B0=80=20#135?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/dto/MemberInfoResponses.kt | 90 ++++++++++++++----- 1 file changed, 70 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt index aaba126..d6a4f51 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberInfoResponses.kt @@ -1,25 +1,13 @@ 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 - -@Schema(description = "회원 승인/가입 현황 리스트 응답") -data class MemberInfoListResponse( - - @Schema(description = "승인 대기 중인 회원 수", example = "5") - val pendingCount: Long, - - @Schema(description = "승인 완료된 회원 수", example = "40") - val approvedCount: Long, - - @Schema(description = "승인 거절된 회원 수", example = "3") - val rejectedCount: Long, - - @Schema(description = "회원 정보 리스트") - val members: List, -) +import onku.backend.global.page.PageResponse @Schema(description = "회원 한 명에 대한 기본/승인 정보") data class MemberItemResponse( @@ -51,12 +39,18 @@ data class MemberItemResponse( @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 MemberApprovalListResponse( +@Schema(description = "회원 페이징 + 상태 별 회원 수 응답") +data class MembersPagedResponse( @Schema(description = "승인 대기 중인 회원 수", example = "5") val pendingCount: Long, @@ -67,6 +61,62 @@ data class MemberApprovalListResponse( @Schema(description = "승인 거절된 회원 수", example = "3") val rejectedCount: Long, - @Schema(description = "회원 승인 정보 리스트") - val members: List, + @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 From 8d75a295866f5dab48a58c73099aee13d892fa94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 13:15:57 +0900 Subject: [PATCH 393/470] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90/=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20isStaff=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=B9=EC=9D=B8=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80=20#135?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/MemberProfileRepository.kt | 20 ++++++++++++++++--- .../member/repository/MemberRepository.kt | 2 ++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt index 1e7a5fa..7db8899 100644 --- a/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberProfileRepository.kt @@ -20,11 +20,25 @@ interface MemberProfileRepository : JpaRepository { @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): List + fun findByMemberApproval( + approval: ApprovalStatus, + pageable: Pageable + ): Page - // PENDING, REJECTED + // STAFF @EntityGraph(attributePaths = ["member"]) - fun findByMemberApprovalIn(approvals: Collection): List + 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 index c9db7c7..7271de9 100644 --- a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt @@ -16,4 +16,6 @@ interface MemberRepository : JpaRepository { """) fun findApprovedMemberIds(): List fun countByApproval(approval: ApprovalStatus): Long + fun findByIsStaffTrue(): List + fun findByIdIn(ids: Collection): List } \ No newline at end of file From c99df5847cc594e824bd47fdc6aead294f8f23d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 13:17:37 +0900 Subject: [PATCH 394/470] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8/=EC=9A=94=EC=B2=AD=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=EC=97=90=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#135?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/service/MemberProfileService.kt | 58 ++++++++++++++----- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index d72c919..87e1a4f 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -9,10 +9,13 @@ 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 @@ -170,14 +173,37 @@ class MemberProfileService( } @Transactional(readOnly = true) - fun getApprovedMemberInfos(): MemberInfoListResponse { + 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 profiles = memberProfileRepository.findByMemberApproval(ApprovalStatus.APPROVED) + val pageable = PageRequest.of( + page, + size, + Sort.by( + Sort.Order.asc("part"), + Sort.Order.asc("name") + ) + ) - val members = profiles.map { profile -> + 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 } @@ -192,29 +218,32 @@ class MemberProfileService( phoneNumber = profile.phoneNumber, socialType = member.socialType, email = member.email, + role = member.role, + isStaff = member.isStaff, approval = member.approval ) } - - return MemberInfoListResponse( + val pageResponse = PageResponse.from(dtoPage) + return MembersPagedResponse( pendingCount = pendingCount, approvedCount = approvedCount, rejectedCount = rejectedCount, - members = members + members = pageResponse ) } @Transactional(readOnly = true) - fun getApprovalRequestMembers(): MemberApprovalListResponse { + 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 profiles = memberProfileRepository.findByMemberApprovalIn( - listOf(ApprovalStatus.PENDING, ApprovalStatus.REJECTED) - ) + val pageable = PageRequest.of(page, size) + + val approvalStatuses = listOf(ApprovalStatus.PENDING, ApprovalStatus.REJECTED) + val profilePage = memberProfileRepository.findByMemberApprovalIn(approvalStatuses, pageable) - val members = profiles.map { profile -> + val memberPage = profilePage.map { profile -> val member = profile.member val key = profile.profileImage val url = key?.let { s3Service.getGetS3Url(member.id!!, it).preSignedUrl } @@ -229,15 +258,18 @@ class MemberProfileService( 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 MemberApprovalListResponse( + return MembersPagedResponse( pendingCount = pendingCount, approvedCount = approvedCount, rejectedCount = rejectedCount, - members = members + members = membersPageResponse ) } } \ No newline at end of file From 1a3fc9b2d0ef61cf3c36af14b82b5b9b5e505033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 13:18:20 +0900 Subject: [PATCH 395/470] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84=20i?= =?UTF-8?q?sStaff=20=EC=97=AC=EB=B6=80=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=EC=A7=84=20ROLE=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20#135?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/MemberService.kt | 79 ++++++++++++++++++- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt index 89996bf..21768d6 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -2,9 +2,7 @@ package onku.backend.domain.member.service import onku.backend.domain.member.Member import onku.backend.domain.member.MemberErrorCode -import onku.backend.domain.member.dto.MemberApprovalResponse -import onku.backend.domain.member.dto.MemberRoleResponse -import onku.backend.domain.member.dto.UpdateRoleRequest +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 @@ -114,4 +112,79 @@ class MemberService( 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 From 3e4cb6b2778108f50d2c87c105724bc224b96fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 13:19:27 +0900 Subject: [PATCH 396/470] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=EA=B4=80=EB=A6=AC=20API=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20member=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=98=20?= =?UTF-8?q?API=20=EA=B6=8C=ED=95=9C=EB=B6=84=EA=B8=B0=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20#135?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/MemberExecutiveController.kt | 47 +++++++++++ .../manager/MemberStaffController.kt | 78 +++++++++++++++++++ .../MemberUserController.kt} | 61 ++------------- 3 files changed, 131 insertions(+), 55 deletions(-) create mode 100644 src/main/kotlin/onku/backend/domain/member/controller/manager/MemberExecutiveController.kt create mode 100644 src/main/kotlin/onku/backend/domain/member/controller/manager/MemberStaffController.kt rename src/main/kotlin/onku/backend/domain/member/controller/{MemberController.kt => user/MemberUserController.kt} (53%) 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..29bb0bb --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/controller/manager/MemberStaffController.kt @@ -0,0 +1,78 @@ +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("/{memberId}/approval") + fun updateApproval( + @PathVariable memberId: Long, + @RequestBody @Valid body: UpdateApprovalRequest + ): ResponseEntity> { + val result = memberService.updateApproval(memberId, body.status) + 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/MemberController.kt b/src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt similarity index 53% rename from src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt rename to src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt index 620d5e6..16b55bc 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/MemberController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt @@ -1,4 +1,4 @@ -package onku.backend.domain.member.controller +package onku.backend.domain.member.controller.user import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag @@ -16,12 +16,11 @@ import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/members") -@Tag(name = "회원 API", description = "온보딩 및 프로필 관련 API") -class MemberController( +@Tag(name = "[USER] 회원 API", description = "온보딩 및 프로필 관련 API") +class MemberUserController( private val memberProfileService: MemberProfileService, private val memberService: MemberService ) { - @PostMapping("/onboarding") @ResponseStatus(HttpStatus.ACCEPTED) @Operation( @@ -62,23 +61,10 @@ class MemberController( return ResponseEntity.ok(SuccessResponse.ok(dto)) } - @Operation( - summary = "[STAFF] 회원 승인 상태 변경", - description = "PENDING 상태의 회원만 승인/거절할 수 있습니다. (PENDING → APPROVED/REJECTED)" - ) - @PatchMapping("/{memberId}/approval") - fun updateApproval( - @PathVariable memberId: Long, - @RequestBody @Valid body: UpdateApprovalRequest - ): ResponseEntity> { - val result = memberService.updateApproval(memberId, body.status) - return ResponseEntity.ok(SuccessResponse.ok(result)) - } - @PatchMapping("/{memberId}/role") @Operation( - summary = "[EXECUTIVE] 사용자 권한 수정", - description = "URL 의 memberId 에 해당하는 사용자의 권한을 role 로 수정합니다." + summary = "[QA] 사용자 권한 수정", + description = "memberId 에 해당하는 사용자의 권한을 role 로 수정합니다. QA 혹은 프론트엔드 연동 기간동안 권한 제한 없이 자유롭게 사용해주시면 됩니다!" ) fun updateRole( @PathVariable memberId: Long, @@ -87,39 +73,4 @@ class MemberController( val body = memberService.updateRole(memberId, req) return ResponseEntity.ok(SuccessResponse.ok(body)) } - - @PatchMapping("/{memberId}/profile") - @Operation( - summary = "[STAFF] 학회원 프로필 정보 수정", - description = "관리자가 특정 학회원의 [이름, 학교, 학과, 전화번호, 파트]를 수정합니다." - ) - fun updateMemberProfile( - @PathVariable memberId: Long, - @RequestBody @Valid req: MemberProfileUpdateRequest - ): SuccessResponse { - val body = memberProfileService.updateProfile(memberId, req) - return SuccessResponse.ok(body) - } - - @GetMapping("/approvals") - @Operation( - summary = "[STAFF] 학회원 정보 목록 조회 (APPROVED)", - description = "PENDING/APPROVED/REJECTED 수와 함께, APPROVED 상태인 학회원들만 목록으로 반환합니다." - ) - fun getApprovedMembers( - ): SuccessResponse { - val body = memberProfileService.getApprovedMemberInfos() - return SuccessResponse.ok(body) - } - - @GetMapping("/requests") - @Operation( - summary = "[STAFF] 승인 요청 목록 조회 (PENDING/REJECTED)", - description = "PENDING/APPROVED/REJECTED 수와 함께, PENDING 및 REJECTED 상태인 학회원들만 목록으로 반환합니다." - ) - fun getApprovalRequests( - ): SuccessResponse { - val body = memberProfileService.getApprovalRequestMembers() - return SuccessResponse.ok(body) - } -} \ No newline at end of file +} From 9315312f3d2403a38c2cb85f574365646866c4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 13:19:48 +0900 Subject: [PATCH 397/470] =?UTF-8?q?feat:=20=20member=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=9D=98=20API=20=EA=B6=8C=ED=95=9C=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?#135?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/config/SecurityConfig.kt | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index df426bf..9abf22f 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -30,29 +30,26 @@ class SecurityConfig( "/api/v1/auth/reissue", ) - private val ONBOARDING_ENDPOINT = arrayOf( - "/api/v1/members/onboarding/**", + private val ONBOARDING_ENDPOINT = arrayOf( // (GUEST) + "/api/v1/members/onboarding", "/api/v1/members/profile/image/url" ) - private val STAFF_ENDPOINT = arrayOf( // 운영진 + private val STAFF_ENDPOINT = arrayOf( // 운영진 (STAFF) "/api/v1/session/staff/**", - "/api/v1/members/*/approval", - "/api/v1/members/*/profile", - "/api/v1/members/approvals", - "/api/v1/members/requests", + "/api/v1/members/staff/**", "/api/v1/notice/manage/**", ) - private val MANAGEMENT_ENDPOINT = arrayOf( // 경총 + 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( // 회장단 - "/api/v1/members/*/role", + private val EXECUTIVE = arrayOf( // 회장단 (EXECUTIVE) + "/api/v1/members/executive/**", ) } From e1be2327f5940c2d0939d6a18c022c871a700b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 13:46:27 +0900 Subject: [PATCH 398/470] =?UTF-8?q?chore:=20enum=20swagger=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20example=20=EC=88=98=EC=A0=95=20#135?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt b/src/main/kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt index bd3883b..5181fa1 100644 --- a/src/main/kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt +++ b/src/main/kotlin/onku/backend/domain/member/enums/ApprovalStatus.kt @@ -4,7 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema @Schema( description = "회원가입 승인 상태", - example = "PENDING" + example = "APPROVED" ) enum class ApprovalStatus { @Schema(description = "승인 대기") PENDING, From 6ca59fe6012e6c7583d5ad9c71092de7b58c9ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 13:49:16 +0900 Subject: [PATCH 399/470] =?UTF-8?q?fix:=20=EB=8B=A8=EA=B1=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=97=90=EC=84=9C=20PathVariable=EB=A1=9C=20=EB=B0=9B?= =?UTF-8?q?=EB=8D=98=20memberId=EB=A5=BC=20body=EB=A1=9C=20=EB=B0=9B?= =?UTF-8?q?=EA=B8=B0=20=EC=9C=84=ED=95=B4=20dto=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#135?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/dto/UpdateApprovalRequest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/UpdateApprovalRequest.kt b/src/main/kotlin/onku/backend/domain/member/dto/UpdateApprovalRequest.kt index 7823591..fbab282 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/UpdateApprovalRequest.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/UpdateApprovalRequest.kt @@ -5,5 +5,8 @@ import onku.backend.domain.member.enums.ApprovalStatus data class UpdateApprovalRequest( @field:NotNull - val status: ApprovalStatus + val memberId: Long?, + + @field:NotNull + val status: ApprovalStatus? ) \ No newline at end of file From da7da81c91289e6564930d242ece1d779ffee107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 13:50:00 +0900 Subject: [PATCH 400/470] =?UTF-8?q?fix:=20=EB=8B=A8=EA=B1=B4=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=8A=B9=EC=9D=B8=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=A1=9C=EC=A7=81=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8B=A4=EA=B1=B4=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=20#13?= =?UTF-8?q?5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/MemberService.kt | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt index 21768d6..53af2b6 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -67,31 +67,47 @@ class MemberService( } @Transactional - fun updateApproval(memberId: Long, targetStatus: ApprovalStatus): MemberApprovalResponse { - if (targetStatus == ApprovalStatus.PENDING) { - throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) + 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 member: Member = memberRepository.findById(memberId) - .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + val memberMap = members.associateBy { it.id!! } + val responses = mutableListOf() - if (member.approval != ApprovalStatus.PENDING) { - throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) - } + items.forEach { item -> + val memberId = item.memberId ?: throw CustomException(MemberErrorCode.INVALID_REQUEST) + val targetStatus = item.status ?: throw CustomException(MemberErrorCode.INVALID_REQUEST) - when (targetStatus) { - ApprovalStatus.APPROVED -> member.approve() - ApprovalStatus.REJECTED -> member.reject() - ApprovalStatus.PENDING -> {} - } + if (targetStatus == ApprovalStatus.PENDING) { + throw CustomException(MemberErrorCode.INVALID_MEMBER_STATE) + } - val saved = memberRepository.save(member) + val member = memberMap[memberId] + ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) - return MemberApprovalResponse( - memberId = saved.id!!, - role = saved.role, - approval = saved.approval - ) + 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 From 296ac404957dd6595d1b7f266728501a6920569e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 13:50:35 +0900 Subject: [PATCH 401/470] =?UTF-8?q?feat:=20controller=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20=EC=A0=81=EC=9A=A9=20#13?= =?UTF-8?q?5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/manager/MemberStaffController.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 index 29bb0bb..27ed262 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/manager/MemberStaffController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/manager/MemberStaffController.kt @@ -19,15 +19,14 @@ class MemberStaffController( ) { @Operation( - summary = "회원 승인 상태 변경", + summary = "회원 승인 상태 일괄 변경", description = "PENDING 상태의 회원만 승인/거절할 수 있습니다. (PENDING → APPROVED/REJECTED)" ) - @PatchMapping("/{memberId}/approval") + @PatchMapping("/approvals") fun updateApproval( - @PathVariable memberId: Long, - @RequestBody @Valid body: UpdateApprovalRequest - ): ResponseEntity> { - val result = memberService.updateApproval(memberId, body.status) + @RequestBody @Valid body: List + ): ResponseEntity>> { + val result = memberService.updateApprovals(body) return ResponseEntity.ok(SuccessResponse.ok(result)) } From 76dc9c836ee0684d0f0c54a738666c4dfb449027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 14:03:32 +0900 Subject: [PATCH 402/470] =?UTF-8?q?fix:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=8B=A8=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20formatting=20=EC=98=A4=ED=83=88=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/notice/util/NoticeDtoMapper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/util/NoticeDtoMapper.kt b/src/main/kotlin/onku/backend/domain/notice/util/NoticeDtoMapper.kt index b3d743a..e651fd4 100644 --- a/src/main/kotlin/onku/backend/domain/notice/util/NoticeDtoMapper.kt +++ b/src/main/kotlin/onku/backend/domain/notice/util/NoticeDtoMapper.kt @@ -7,7 +7,7 @@ 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") +private val fmtDetail = DateTimeFormatter.ofPattern("MM/dd HH:mm") object NoticeDtoMapper { fun toCategoryBadge(c: NoticeCategory) = From 1d63c0b9313893f74c6a26101085fed4b5e24e02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 14:31:39 +0900 Subject: [PATCH 403/470] =?UTF-8?q?refactor:=20=EA=B8=B0=ED=9A=8D=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=EC=97=90=20=EB=A7=9E=EC=B6=B0=EC=84=9C=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=83=89=EC=83=81=20=EC=A2=85=EB=A5=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?#139?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/enums/NoticeCategoryColor.kt | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt b/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt index 6fb5495..d8e435d 100644 --- a/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt +++ b/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt @@ -1,5 +1,36 @@ package onku.backend.domain.notice.enums +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "공지 카테고리 색상") enum class NoticeCategoryColor { - RED, ORANGE, YELLOW, GREEN, TEAL, BLUE, INDIGO, PURPLE, PINK, BROWN, GRAY + @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 From 2bb753ec443d7d86fc5b046b838902ebcd10cfa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 14:35:26 +0900 Subject: [PATCH 404/470] =?UTF-8?q?refactor:=20enum=EA=B0=92=EA=B3=BC=20?= =?UTF-8?q?=EC=83=89=EC=83=81=EB=AA=85=20=EB=A7=A4=ED=95=91=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94=20=EC=B6=94=EA=B0=80=20#139?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/enums/NoticeCategoryColor.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt b/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt index d8e435d..bdba5bd 100644 --- a/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt +++ b/src/main/kotlin/onku/backend/domain/notice/enums/NoticeCategoryColor.kt @@ -2,7 +2,21 @@ package onku.backend.domain.notice.enums import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "공지 카테고리 색상") +@Schema( + description = """ +공지 카테고리 색상: +- RED (빨강) +- ORANGE (주황) +- YELLOW (노랑) +- GREEN (초록) +- LIGHT_GREEN (연두) +- TEAL (청록) +- BLUE (파랑) +- PURPLE (보라) +- PINK (분홍) +- BROWN (갈색) +""" +) enum class NoticeCategoryColor { @Schema(description = "빨강") RED, From d9f83a58cfb420e2f1d90b5e7307b83bb7f198b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 15:19:02 +0900 Subject: [PATCH 405/470] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EC=99=80=20=EB=8F=99=EC=8B=9C=EC=97=90=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=ED=81=AC=EA=B8=B0=EB=A5=BC=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EB=B0=9B=EC=95=84=EC=99=80=20=EC=A0=80=EC=9E=A5=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=9C=84=ED=95=B4=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EC=97=90=20attachmentSize=20=EC=B6=94=EA=B0=80=20#141?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/notice/NoticeAttachment.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/NoticeAttachment.kt b/src/main/kotlin/onku/backend/domain/notice/NoticeAttachment.kt index e349b95..c179b00 100644 --- a/src/main/kotlin/onku/backend/domain/notice/NoticeAttachment.kt +++ b/src/main/kotlin/onku/backend/domain/notice/NoticeAttachment.kt @@ -21,4 +21,7 @@ class NoticeAttachment( @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 From be162b2a2b7fe5d9b0f6684febe562c0c0af0ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 15:19:58 +0900 Subject: [PATCH 406/470] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20=ED=8C=8C=EC=9D=BC=20=EB=B3=84=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=EB=A5=BC=20=ED=95=A8=EA=BB=98=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=B4=20dto=EC=97=90?= =?UTF-8?q?=20size=20=EC=B6=94=EA=B0=80=20#141?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/dto/notice/NoticeFileWithUrl.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 index c58d3d2..e2da98b 100644 --- a/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeFileWithUrl.kt +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeFileWithUrl.kt @@ -1,6 +1,16 @@ package onku.backend.domain.notice.dto.notice -data class NoticeFileWithUrl ( +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "공지 첨부파일 + presigned URL") +data class NoticeFileWithUrl( + + @Schema(description = "첨부파일 ID", example = "1") val id: Long, - val url: String + + @Schema(description = "첨부파일 다운로드 URL") + val url: String, + + @Schema(description = "첨부파일 크기", example = "123456") + val size: Long? ) \ No newline at end of file From eec623e1ea53d007535af0b2b9415293a45d6d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 15:20:27 +0900 Subject: [PATCH 407/470] =?UTF-8?q?feat:=20dto=EC=99=80=20entity=EC=9D=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?#141?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/service/NoticeAttachmentService.kt | 8 +++++--- .../onku/backend/domain/notice/service/NoticeService.kt | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/service/NoticeAttachmentService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeAttachmentService.kt index e7d8a52..dc7de99 100644 --- a/src/main/kotlin/onku/backend/domain/notice/service/NoticeAttachmentService.kt +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeAttachmentService.kt @@ -22,7 +22,8 @@ class NoticeAttachmentService( fun prepareUpload( currentMember: Member, filename: String, - fileType: UploadOption + fileType: UploadOption, + fileSize: Long, ): PresignedUploadResponse { val put: GetS3UrlDto = s3Service.getPostS3Url( @@ -36,7 +37,8 @@ class NoticeAttachmentService( NoticeAttachment( notice = null, s3Key = put.key, - attachmentType = fileType + attachmentType = fileType, + attachmentSize = fileSize ) ) @@ -45,4 +47,4 @@ class NoticeAttachmentService( presignedUrl = put.preSignedUrl ) } -} +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt index cc27f1e..7546539 100644 --- a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt @@ -196,14 +196,16 @@ class NoticeService( val imageDtos = imageFiles.map { file -> NoticeFileWithUrl( id = file.id!!, - url = presignGet(memberId, file.s3Key).preSignedUrl + url = presignGet(memberId, file.s3Key).preSignedUrl, + size = file.attachmentSize ) } val fileDtos = otherFiles.map { file -> NoticeFileWithUrl( id = file.id!!, - url = presignGet(memberId, file.s3Key).preSignedUrl + url = presignGet(memberId, file.s3Key).preSignedUrl, + size = file.attachmentSize ) } From f7162803d8f5ab59a5f3e6bd5a3b32b877a2fc77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 15:21:07 +0900 Subject: [PATCH 408/470] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20presignedURL?= =?UTF-8?q?=20=EB=B0=9C=EA=B8=89=20=EC=8B=9C=20RequestParam=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20fileSize=EB=8F=84=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20#141?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/controller/manager/NoticeManagerController.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index f0f8176..d8e11a5 100644 --- a/src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeManagerController.kt +++ b/src/main/kotlin/onku/backend/domain/notice/controller/manager/NoticeManagerController.kt @@ -43,14 +43,15 @@ class NoticeManagerController( @PostMapping("files") @Operation( summary = "공지 이미지/파일 업로드 URL 발급 [운영진]", - description = "filename을 받아 presigned PUT 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) + val body = noticeAttachmentService.prepareUpload(member, filename, fileType, fileSize) return ResponseEntity.ok(SuccessResponse.ok(body)) } @@ -77,4 +78,4 @@ class NoticeManagerController( noticeService.delete(noticeId) return ResponseEntity.ok(SuccessResponse.ok(Unit)) } -} +} \ No newline at end of file From 47c93635b0a1d035884840ff7995a5cddc8ca15a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 19 Nov 2025 15:28:56 +0900 Subject: [PATCH 409/470] =?UTF-8?q?bug=20:=20fcm.json=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 01a68ab..9905070 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -134,6 +134,7 @@ jobs: run: | cd ./src rm -rf main/resources/application.yml + rm -rf main/resources/firebase/fcm.json mkdir -p main/resources mkdir -p test/resources mkdir -p main/resources/firebase From 3d05576b2368905fef2c4bfc4dea02f5ba2788a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 19 Nov 2025 15:32:36 +0900 Subject: [PATCH 410/470] =?UTF-8?q?fix=20:=20cicd=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EA=B5=AC=EB=AC=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9905070..5494912 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -129,12 +129,11 @@ jobs: env: APPLICATION_PROPERTIES: ${{ secrets.ONKU_APP_PROD_YML }} TEST_APPLICATION_PROPERTIES: ${{ secrets.ONKU_APP_TEST_YML }} - FCM_JSON: ${ secrets.ONKU_FCM_JSON }} + FCM_JSON: ${{ secrets.ONKU_FCM_JSON }} run: | cd ./src rm -rf main/resources/application.yml - rm -rf main/resources/firebase/fcm.json mkdir -p main/resources mkdir -p test/resources mkdir -p main/resources/firebase From eddb0f91420715799a34f054e8eabb069470cbef Mon Sep 17 00:00:00 2001 From: kimyeoungrok <127182406+kimyeoungrok@users.noreply.github.com> Date: Wed, 19 Nov 2025 15:38:34 +0900 Subject: [PATCH 411/470] Update deploy.yml --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5494912..8a9d909 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,7 +46,7 @@ jobs: env: APPLICATION_PROPERTIES: ${{ secrets.ONKU_APP_DEV_YML }} TEST_APPLICATION_PROPERTIES: ${{ secrets.ONKU_APP_TEST_YML }} - FCM_JSON: ${ secrets.ONKU_FCM_JSON }} + FCM_JSON: ${{ secrets.ONKU_FCM_JSON }} run: | cd ./src @@ -192,4 +192,4 @@ jobs: export REDIS_PASSWORD=${{ secrets.PROD_REDIS_PASSWORD }} export GITHUB_SHA=${{ github.sha }} sudo chmod +x ./deploy.sh - ./deploy.sh \ No newline at end of file + ./deploy.sh From 2f0c19508e98720fca007d93451e681af9091aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 18:05:45 +0900 Subject: [PATCH 412/470] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=EB=A5=BC=20=EB=82=98=ED=83=80=EB=82=B4=EB=8A=94=20enu?= =?UTF-8?q?m=20=EC=B6=94=EA=B0=80=20#147?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/enums/MemberAlarmType.kt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/enums/MemberAlarmType.kt diff --git a/src/main/kotlin/onku/backend/domain/member/enums/MemberAlarmType.kt b/src/main/kotlin/onku/backend/domain/member/enums/MemberAlarmType.kt new file mode 100644 index 0000000..a02911c --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/enums/MemberAlarmType.kt @@ -0,0 +1,6 @@ +package onku.backend.domain.member.enums + +enum class MemberAlarmType { + KUPICK, + ABSENCE_REPORT, +} From ce8682f66a4f4c84f0922a66663a726591666ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 18:06:25 +0900 Subject: [PATCH 413/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=EC=95=8C=EB=A6=BC=EA=B8=B0=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80=20#147?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/MemberAlarmHistory.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt 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..15cbf4c --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt @@ -0,0 +1,29 @@ +package onku.backend.domain.member + +import onku.backend.domain.member.enums.MemberAlarmType +import jakarta.persistence.* +import java.time.LocalDateTime + +@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: MemberAlarmType, + + @Column(name = "created_at", nullable = false) + val createdAt: LocalDateTime = LocalDateTime.now() +) From 9dfd3880f6b9c729916c55095720d9bcfd79ee09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 18:06:48 +0900 Subject: [PATCH 414/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=EC=95=8C=EB=A6=BC=EA=B8=B0=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=9A=A9=20dto=20=EC=B6=94=EA=B0=80=20#147?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/MemberAlarmHistoryItemResponse.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt 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..afdb88d --- /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.domain.member.enums.MemberAlarmType + +@Schema(description = "내 알림 히스토리 한 건 응답") +data class MemberAlarmHistoryItemResponse( + + @Schema(description = "알림 메시지 내용") + val message: String?, + + @Schema(description = "알림 타입") + val type: MemberAlarmType, + + @Schema(description = "알림 생성 시각, 포맷: MM/dd HH:mm", example = "11/19 13:45") + val createdAt: String +) From b44377a27b99b833512482ef23dfc87bd10f045c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 18:07:17 +0900 Subject: [PATCH 415/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=EC=95=8C=EB=A6=BC=EA=B8=B0=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20#147?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/repository/MemberAlarmHistoryRepository.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/repository/MemberAlarmHistoryRepository.kt 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 +} From 4f5d6fb43f4f44c5f736f2a35388263c46e29a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 18:08:30 +0900 Subject: [PATCH 416/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=EC=95=8C=EB=A6=BC=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80=20#147?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MemberAlarmHistoryService.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/member/service/MemberAlarmHistoryService.kt 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..332163a --- /dev/null +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberAlarmHistoryService.kt @@ -0,0 +1,29 @@ +package onku.backend.domain.member.service + +import onku.backend.domain.member.Member +import onku.backend.domain.member.dto.MemberAlarmHistoryItemResponse +import onku.backend.domain.member.repository.MemberAlarmHistoryRepository +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) + ) + } + } +} From f456024cb7d99e846f651348e5d0788e48cc016d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 18:08:50 +0900 Subject: [PATCH 417/470] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=EC=95=8C=EB=A6=BC=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#147?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/user/MemberUserController.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 index 16b55bc..cd2b35d 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt @@ -5,6 +5,7 @@ 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 @@ -19,7 +20,8 @@ import org.springframework.web.bind.annotation.* @Tag(name = "[USER] 회원 API", description = "온보딩 및 프로필 관련 API") class MemberUserController( private val memberProfileService: MemberProfileService, - private val memberService: MemberService + private val memberService: MemberService, + private val memberAlarmHistoryService: MemberAlarmHistoryService, ) { @PostMapping("/onboarding") @ResponseStatus(HttpStatus.ACCEPTED) @@ -73,4 +75,16 @@ class MemberUserController( val body = memberService.updateRole(memberId, req) return ResponseEntity.ok(SuccessResponse.ok(body)) } + + @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) + } } From 7b41f0b89540c2ad2738441010cac3027a4e9863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Wed, 19 Nov 2025 18:12:10 +0900 Subject: [PATCH 418/470] =?UTF-8?q?refactor:=20=EC=95=8C=EB=9E=8C=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=EB=A5=BC=20object=EC=97=90=EC=84=9C=20enum?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20#147?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/absence/facade/AbsenceFacade.kt | 4 ++-- .../onku/backend/domain/kupick/facade/KupickFacade.kt | 4 ++-- .../onku/backend/domain/member/MemberAlarmHistory.kt | 4 ++-- .../domain/member/dto/MemberAlarmHistoryItemResponse.kt | 4 ++-- .../onku/backend/domain/member/enums/MemberAlarmType.kt | 6 ------ src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt | 6 ------ .../kotlin/onku/backend/global/alarm/enums/AlarmType.kt | 8 ++++++++ 7 files changed, 16 insertions(+), 20 deletions(-) delete mode 100644 src/main/kotlin/onku/backend/domain/member/enums/MemberAlarmType.kt delete mode 100644 src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt create mode 100644 src/main/kotlin/onku/backend/global/alarm/enums/AlarmType.kt diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index c37e8a3..877f8a2 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -9,11 +9,11 @@ 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.AlarmType 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.AlarmTitle import onku.backend.global.alarm.FCMService import onku.backend.global.exception.CustomException import onku.backend.global.s3.dto.GetPreSignedUrlDto @@ -107,7 +107,7 @@ class AbsenceFacade( memberPointHistoryService.upsertPointFromAbsenceReport(absenceReport) val now = LocalDateTime.now() if(!absenceReport.member.fcmToken.isNullOrBlank()){ - fcmService.sendMessageTo(absenceReport.member.fcmToken!!, AlarmTitle.ABSENCE_REPORT, AlarmMessage.absenceReport(now.month.value, now.dayOfMonth, estimateAbsenceReportRequest.approvedType), null) + fcmService.sendMessageTo(absenceReport.member.fcmToken!!, AlarmType.ABSENCE_REPORT.title, AlarmMessage.absenceReport(now.month.value, now.dayOfMonth, estimateAbsenceReportRequest.approvedType), null) } return true } diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index 1dcddc1..575e870 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -5,8 +5,8 @@ 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.AlarmType import onku.backend.global.alarm.AlarmMessage -import onku.backend.global.alarm.AlarmTitle import onku.backend.global.alarm.FCMService import onku.backend.global.s3.dto.GetUpdateAndDeleteUrlDto import onku.backend.global.s3.enums.FolderName @@ -95,7 +95,7 @@ class KupickFacade( fcmToken?.let { fcmService.sendMessageTo( targetToken = it, - title = AlarmTitle.KUPICK, + title = AlarmType.KUPICK.title, body = AlarmMessage.kupick(submitMonth!!, kupickApprovalRequest.approval), link = null ) diff --git a/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt b/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt index 15cbf4c..279c047 100644 --- a/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt +++ b/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt @@ -1,6 +1,6 @@ package onku.backend.domain.member -import onku.backend.domain.member.enums.MemberAlarmType +import onku.backend.global.alarm.enums.AlarmType import jakarta.persistence.* import java.time.LocalDateTime @@ -22,7 +22,7 @@ class MemberAlarmHistory( @Enumerated(EnumType.STRING) @Column(name = "type", nullable = false) - val type: MemberAlarmType, + val type: AlarmType, @Column(name = "created_at", nullable = false) val createdAt: LocalDateTime = LocalDateTime.now() diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt index afdb88d..d316f71 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt @@ -1,7 +1,7 @@ package onku.backend.domain.member.dto import io.swagger.v3.oas.annotations.media.Schema -import onku.backend.domain.member.enums.MemberAlarmType +import onku.backend.global.alarm.enums.AlarmType @Schema(description = "내 알림 히스토리 한 건 응답") data class MemberAlarmHistoryItemResponse( @@ -10,7 +10,7 @@ data class MemberAlarmHistoryItemResponse( val message: String?, @Schema(description = "알림 타입") - val type: MemberAlarmType, + val type: AlarmType, @Schema(description = "알림 생성 시각, 포맷: MM/dd HH:mm", example = "11/19 13:45") val createdAt: String diff --git a/src/main/kotlin/onku/backend/domain/member/enums/MemberAlarmType.kt b/src/main/kotlin/onku/backend/domain/member/enums/MemberAlarmType.kt deleted file mode 100644 index a02911c..0000000 --- a/src/main/kotlin/onku/backend/domain/member/enums/MemberAlarmType.kt +++ /dev/null @@ -1,6 +0,0 @@ -package onku.backend.domain.member.enums - -enum class MemberAlarmType { - KUPICK, - ABSENCE_REPORT, -} diff --git a/src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt b/src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt deleted file mode 100644 index d84c09c..0000000 --- a/src/main/kotlin/onku/backend/global/alarm/AlarmTitle.kt +++ /dev/null @@ -1,6 +0,0 @@ -package onku.backend.global.alarm - -object AlarmTitle { - const val KUPICK = "큐픽 관련 알림입니다." - const val ABSENCE_REPORT = "불참 사유서 관련 알림입니다." -} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/alarm/enums/AlarmType.kt b/src/main/kotlin/onku/backend/global/alarm/enums/AlarmType.kt new file mode 100644 index 0000000..1d09696 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/alarm/enums/AlarmType.kt @@ -0,0 +1,8 @@ +package onku.backend.global.alarm.enums + +enum class AlarmType( + val title: String, +) { + KUPICK("큐픽 관련 알림입니다."), + ABSENCE_REPORT("불참 사유서 관련 알림입니다."), +} \ No newline at end of file From 21bfc2566fc6cb1b721d581832434b1c8394c366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 19 Nov 2025 21:38:12 +0900 Subject: [PATCH 419/470] =?UTF-8?q?chore=20:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20#149?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/alarm/FCMTestController.kt | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 src/main/kotlin/onku/backend/global/alarm/FCMTestController.kt diff --git a/src/main/kotlin/onku/backend/global/alarm/FCMTestController.kt b/src/main/kotlin/onku/backend/global/alarm/FCMTestController.kt deleted file mode 100644 index dde2ac5..0000000 --- a/src/main/kotlin/onku/backend/global/alarm/FCMTestController.kt +++ /dev/null @@ -1,29 +0,0 @@ -package onku.backend.global.alarm - -import io.swagger.v3.oas.annotations.Hidden -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag -import onku.backend.global.response.SuccessResponse -import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController - -@Hidden -@RestController -@RequestMapping("/test/push") -@Tag(name = "푸시 알림 테스트용 API") -class FCMTestController( - private val fcmService: FCMService -) { - @ResponseStatus(HttpStatus.OK) - @GetMapping("") - @Operation(summary = "푸시 알림 테스트", description = "현재 프론트랑 연동이 안되어서 로그만 확인가능") - fun pushTest( - token : String - ): SuccessResponse { - fcmService.sendMessageTo(token, "알림 제목", "알림 내용", "알림 눌렀을 때 연결되는 링크") - return SuccessResponse.ok(true) - } -} \ No newline at end of file From 0b502ae5177d817c36242e0ef14e8cde5fa94ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 19 Nov 2025 21:38:46 +0900 Subject: [PATCH 420/470] =?UTF-8?q?feat=20:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=9E=AC=EC=A0=95=EC=9D=98=20#149?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/alarm/enums/AlarmEmojiType.kt | 7 +++++++ .../global/alarm/enums/{AlarmType.kt => AlarmTitleType.kt} | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/onku/backend/global/alarm/enums/AlarmEmojiType.kt rename src/main/kotlin/onku/backend/global/alarm/enums/{AlarmType.kt => AlarmTitleType.kt} (86%) 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/AlarmType.kt b/src/main/kotlin/onku/backend/global/alarm/enums/AlarmTitleType.kt similarity index 86% rename from src/main/kotlin/onku/backend/global/alarm/enums/AlarmType.kt rename to src/main/kotlin/onku/backend/global/alarm/enums/AlarmTitleType.kt index 1d09696..632ed5f 100644 --- a/src/main/kotlin/onku/backend/global/alarm/enums/AlarmType.kt +++ b/src/main/kotlin/onku/backend/global/alarm/enums/AlarmTitleType.kt @@ -1,6 +1,6 @@ package onku.backend.global.alarm.enums -enum class AlarmType( +enum class AlarmTitleType( val title: String, ) { KUPICK("큐픽 관련 알림입니다."), From ff7e6755dd16175a1e5e18236796d85478ca2b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 19 Nov 2025 21:39:25 +0900 Subject: [PATCH 421/470] =?UTF-8?q?refactor=20:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=EC=97=90=EC=84=9C=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=AA=A8=EC=A7=80=20=ED=83=80=EC=9E=85=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EA=B2=8C=20=EB=B3=80=EA=B2=BD=20#149?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/MemberAlarmHistory.kt | 12 ++++-------- .../member/dto/MemberAlarmHistoryItemResponse.kt | 6 +++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt b/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt index 279c047..5cd50da 100644 --- a/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt +++ b/src/main/kotlin/onku/backend/domain/member/MemberAlarmHistory.kt @@ -1,9 +1,8 @@ package onku.backend.domain.member -import onku.backend.global.alarm.enums.AlarmType import jakarta.persistence.* -import java.time.LocalDateTime - +import onku.backend.global.alarm.enums.AlarmEmojiType +import onku.backend.global.entity.BaseEntity @Entity @Table(name = "member_alarm_history") class MemberAlarmHistory( @@ -22,8 +21,5 @@ class MemberAlarmHistory( @Enumerated(EnumType.STRING) @Column(name = "type", nullable = false) - val type: AlarmType, - - @Column(name = "created_at", nullable = false) - val createdAt: LocalDateTime = LocalDateTime.now() -) + val type: AlarmEmojiType, +) : BaseEntity() diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt index d316f71..68d17ff 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberAlarmHistoryItemResponse.kt @@ -1,7 +1,7 @@ package onku.backend.domain.member.dto import io.swagger.v3.oas.annotations.media.Schema -import onku.backend.global.alarm.enums.AlarmType +import onku.backend.global.alarm.enums.AlarmEmojiType @Schema(description = "내 알림 히스토리 한 건 응답") data class MemberAlarmHistoryItemResponse( @@ -9,8 +9,8 @@ data class MemberAlarmHistoryItemResponse( @Schema(description = "알림 메시지 내용") val message: String?, - @Schema(description = "알림 타입") - val type: AlarmType, + @Schema(description = "알림 이모지 타입") + val type: AlarmEmojiType, @Schema(description = "알림 생성 시각, 포맷: MM/dd HH:mm", example = "11/19 13:45") val createdAt: String From d51c960af8abcd2505e1078d7c66ad7d455e325d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 19 Nov 2025 21:44:10 +0900 Subject: [PATCH 422/470] =?UTF-8?q?refactor=20:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=97=90=20=EC=A0=80=EC=9E=A5=EB=90=98=EA=B2=8C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20#149?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/absence/facade/AbsenceFacade.kt | 7 +++--- .../{KupickFcmInfo.kt => KupickMemberInfo.kt} | 5 ++-- .../domain/kupick/facade/KupickFacade.kt | 23 +++++++++++-------- .../kupick/repository/KupickRepository.kt | 6 ++--- .../domain/kupick/service/KupickService.kt | 4 ++-- .../service/MemberAlarmHistoryService.kt | 13 +++++++++++ .../onku/backend/global/alarm/FCMService.kt | 15 ++++++++++-- 7 files changed, 50 insertions(+), 23 deletions(-) rename src/main/kotlin/onku/backend/domain/kupick/dto/{KupickFcmInfo.kt => KupickMemberInfo.kt} (53%) diff --git a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt index 877f8a2..0d51e1c 100644 --- a/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt +++ b/src/main/kotlin/onku/backend/domain/absence/facade/AbsenceFacade.kt @@ -9,12 +9,13 @@ 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.AlarmType +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 @@ -106,9 +107,7 @@ class AbsenceFacade( absenceReport.updateApproval(AbsenceReportApproval.APPROVED) memberPointHistoryService.upsertPointFromAbsenceReport(absenceReport) val now = LocalDateTime.now() - if(!absenceReport.member.fcmToken.isNullOrBlank()){ - fcmService.sendMessageTo(absenceReport.member.fcmToken!!, AlarmType.ABSENCE_REPORT.title, AlarmMessage.absenceReport(now.month.value, now.dayOfMonth, estimateAbsenceReportRequest.approvedType), null) - } + fcmService.sendMessageTo(absenceReport.member, AlarmTitleType.ABSENCE_REPORT, AlarmEmojiType.WARNING, AlarmMessage.absenceReport(now.month.value, now.dayOfMonth, estimateAbsenceReportRequest.approvedType), null) return true } diff --git a/src/main/kotlin/onku/backend/domain/kupick/dto/KupickFcmInfo.kt b/src/main/kotlin/onku/backend/domain/kupick/dto/KupickMemberInfo.kt similarity index 53% rename from src/main/kotlin/onku/backend/domain/kupick/dto/KupickFcmInfo.kt rename to src/main/kotlin/onku/backend/domain/kupick/dto/KupickMemberInfo.kt index 509eed9..f71b664 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/dto/KupickFcmInfo.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/dto/KupickMemberInfo.kt @@ -1,8 +1,9 @@ package onku.backend.domain.kupick.dto +import onku.backend.domain.member.Member import java.time.LocalDateTime -data class KupickFcmInfo( - val fcmToken: String?, +data class KupickMemberInfo( + val member: Member, val submitDate: LocalDateTime? ) diff --git a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt index 575e870..9c72164 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/facade/KupickFacade.kt @@ -5,9 +5,10 @@ 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.AlarmType +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 @@ -90,16 +91,18 @@ class KupickFacade( fun decideApproval(kupickApprovalRequest: KupickApprovalRequest): Boolean { kupickService.decideApproval(kupickApprovalRequest.kupickId, kupickApprovalRequest.approval) val info = kupickService.findFcmInfo(kupickApprovalRequest.kupickId) - val fcmToken = info?.fcmToken + val member = info?.member val submitMonth = info?.submitDate?.monthValue - fcmToken?.let { - fcmService.sendMessageTo( - targetToken = it, - title = AlarmType.KUPICK.title, - body = AlarmMessage.kupick(submitMonth!!, kupickApprovalRequest.approval), - link = null - ) - } + 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 index 7a2d10e..cea5ef9 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/repository/KupickRepository.kt @@ -1,7 +1,7 @@ package onku.backend.domain.kupick.repository import onku.backend.domain.kupick.Kupick -import onku.backend.domain.kupick.dto.KupickFcmInfo +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 @@ -74,10 +74,10 @@ interface KupickRepository : JpaRepository { ): List> @Query(""" - SELECT new onku.backend.domain.kupick.dto.KupickFcmInfo(m.fcmToken, k.submitDate) + 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): KupickFcmInfo? + fun findFcmInfoByKupickId(@Param("kupickId") kupickId: Long): KupickMemberInfo? } \ 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 index ae87b84..65f845b 100644 --- a/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt +++ b/src/main/kotlin/onku/backend/domain/kupick/service/KupickService.kt @@ -2,7 +2,7 @@ package onku.backend.domain.kupick.service import onku.backend.domain.kupick.Kupick import onku.backend.domain.kupick.KupickErrorCode -import onku.backend.domain.kupick.dto.KupickFcmInfo +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 @@ -83,7 +83,7 @@ class KupickService( } @Transactional(readOnly = true) - fun findFcmInfo(kupickId: Long) : KupickFcmInfo? { + 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/service/MemberAlarmHistoryService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberAlarmHistoryService.kt index 332163a..b8b54cd 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberAlarmHistoryService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberAlarmHistoryService.kt @@ -1,8 +1,10 @@ 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 @@ -26,4 +28,15 @@ class MemberAlarmHistoryService( ) } } + + @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/global/alarm/FCMService.kt b/src/main/kotlin/onku/backend/global/alarm/FCMService.kt index 04185fa..4bdc813 100644 --- a/src/main/kotlin/onku/backend/global/alarm/FCMService.kt +++ b/src/main/kotlin/onku/backend/global/alarm/FCMService.kt @@ -7,6 +7,10 @@ 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 @@ -20,6 +24,7 @@ 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}") @@ -32,8 +37,12 @@ class FCMService( private val client: OkHttpClient = OkHttpClient() } @Throws(IOException::class) - fun sendMessageTo(targetToken: String, title: String, body: String, link: String?) { - val message = makeMessage(targetToken, title, body, link) + 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()) @@ -47,6 +56,8 @@ class FCMService( log.info("제목 : $title, 내용 : $body") + memberAlarmHistoryService.saveAlarm(member, alarmEmojiType, body) + client.newCall(request).execute().use { response -> log.info("fcm 결과 : " + response.body?.string()) } From b040367e1a561d6d8484149e138b7ba3631f0d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 20 Nov 2025 15:30:30 +0900 Subject: [PATCH 423/470] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=B0=98=ED=99=98=20=EC=8B=9C=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=9A=94=EC=86=8C=EC=9D=98=20=EA=B0=9C=EC=88=98=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20#151?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/page/PageResponse.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/page/PageResponse.kt b/src/main/kotlin/onku/backend/global/page/PageResponse.kt index dbd532a..04b602a 100644 --- a/src/main/kotlin/onku/backend/global/page/PageResponse.kt +++ b/src/main/kotlin/onku/backend/global/page/PageResponse.kt @@ -6,8 +6,13 @@ import org.springframework.data.domain.Page data class PageResponse( @Schema(description = "페이지 응답") val data: List, + @Schema(description = "전체 페이지 수") val totalPages: Int, + + @Schema(description = "전체 아이템 개수") + val totalElements: Long, + @Schema(description = "마지막 페이지 여부") val isLastPage: Boolean, ) { @@ -17,7 +22,8 @@ data class PageResponse( ): PageResponse = PageResponse( data = page.content, totalPages = page.totalPages, + totalElements = page.totalElements, isLastPage = page.isLast ) } -} \ No newline at end of file +} From 055c5bc8d267f46cbc93ec57aab0f06161d6098c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 20 Nov 2025 15:31:37 +0900 Subject: [PATCH 424/470] =?UTF-8?q?refactor:=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20PageResponse=EB=A5=BC=20Page.map()?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20DTO=20=EB=B3=80=ED=99=98=20=ED=9B=84=20.fr?= =?UTF-8?q?om()=EB=A1=9C=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC=20#151?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/service/NoticeService.kt | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt index 7546539..738a92c 100644 --- a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt @@ -55,17 +55,11 @@ class NoticeService( pageable ) } - - val items = noticePage.content.map { n -> + val dtoPage = noticePage.map { n -> val (imageFiles, fileFiles) = splitPresignedUrls(memberId, n.attachments) NoticeDtoMapper.toListItem(n, imageFiles, fileFiles) } - - return PageResponse( - data = items, - totalPages = noticePage.totalPages, - isLastPage = noticePage.isLast - ) + return PageResponse.from(dtoPage) } fun search( @@ -93,15 +87,11 @@ class NoticeService( pageable ) } - val items = noticePage.content.map { n -> + val dtoPage = noticePage.map { n -> val (imageFiles, fileFiles) = splitPresignedUrls(memberId, n.attachments) NoticeDtoMapper.toListItem(n, imageFiles, fileFiles) } - return PageResponse( - data = items, - totalPages = noticePage.totalPages, - isLastPage = noticePage.isLast - ) + return PageResponse.from(dtoPage) } fun get(noticeId: Long, currentMember: Member): NoticeDetailResponse { From f565dd64377f7332b8eaf7516f2bcedb4dd5bb2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 20 Nov 2025 17:39:14 +0900 Subject: [PATCH 425/470] =?UTF-8?q?refactor=20:=20S3=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9B=90=EB=B3=B8=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85=20=EC=B6=94=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#153?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/s3/dto/GetS3UrlDto.kt | 3 ++- .../onku/backend/global/s3/service/S3Service.kt | 14 +++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/s3/dto/GetS3UrlDto.kt b/src/main/kotlin/onku/backend/global/s3/dto/GetS3UrlDto.kt index dce16a7..6e80ffe 100644 --- a/src/main/kotlin/onku/backend/global/s3/dto/GetS3UrlDto.kt +++ b/src/main/kotlin/onku/backend/global/s3/dto/GetS3UrlDto.kt @@ -2,5 +2,6 @@ package onku.backend.global.s3.dto data class GetS3UrlDto( val preSignedUrl: String, - val key: String + val key: String, + val originalName : String ) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt index 049f8b9..4d97269 100644 --- a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -27,7 +27,7 @@ class S3Service( @Transactional(readOnly = true) fun getPostS3Url(memberId: Long, filename: String?, folderName : String, option : UploadOption): GetS3UrlDto { if(filename.isNullOrBlank()) { - return GetS3UrlDto(preSignedUrl = "", key = "") + return GetS3UrlDto(preSignedUrl = "", key = "", originalName = "") } val key = "$folderName/$memberId/${UUID.randomUUID()}/$filename" @@ -52,13 +52,13 @@ class S3Service( val presigned = s3Presigner.presignPutObject(presignReq) val url: URL = presigned.url() - return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key) + return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key, originalName = getFileOriginalNameFromKey(key)) } @Transactional(readOnly = true) fun getGetS3Url(memberId: Long, key: String?): GetS3UrlDto { if(key.isNullOrBlank()) { - return GetS3UrlDto("", "") + return GetS3UrlDto("", "", "") } val contentType = guessFileType(key) @@ -77,7 +77,7 @@ class S3Service( val presigned = s3Presigner.presignGetObject(presignReq) val url: URL = presigned.url() - return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key) + return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key, originalName = getFileOriginalNameFromKey(key)) } @Transactional(readOnly = true) @@ -95,7 +95,7 @@ class S3Service( val presigned = s3Presigner.presignDeleteObject(presignReq) val url: URL = presigned.url() - return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key) + return GetS3UrlDto(preSignedUrl = url.toExternalForm(), key = key, originalName = getFileOriginalNameFromKey(key)) } fun deleteObjectsNow(keys: List) { @@ -145,6 +145,10 @@ class S3Service( } } + private fun getFileOriginalNameFromKey(key : String) : String { + return key.substringAfterLast("/") + } + companion object { private val DEFAULT_EXPIRE: Duration = Duration.ofMinutes(10) } From 1b610acbdf2a3ddc6bc603a1ac5b43c4bcf1c602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 20 Nov 2025 17:39:42 +0900 Subject: [PATCH 426/470] =?UTF-8?q?refactor=20:=20=EA=B3=B5=EC=A7=80?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EA=B2=8C=20=EC=88=98=EC=A0=95=20#153?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/dto/notice/NoticeFileWithUrl.kt | 5 ++++- .../backend/domain/notice/service/NoticeService.kt | 10 +++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) 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 index e2da98b..093057b 100644 --- a/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeFileWithUrl.kt +++ b/src/main/kotlin/onku/backend/domain/notice/dto/notice/NoticeFileWithUrl.kt @@ -12,5 +12,8 @@ data class NoticeFileWithUrl( val url: String, @Schema(description = "첨부파일 크기", example = "123456") - val size: Long? + 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/service/NoticeService.kt b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt index 738a92c..080966f 100644 --- a/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt +++ b/src/main/kotlin/onku/backend/domain/notice/service/NoticeService.kt @@ -184,18 +184,22 @@ class NoticeService( 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 = presignGet(memberId, file.s3Key).preSignedUrl, - size = file.attachmentSize + 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 + size = file.attachmentSize, + originalFileName = preSignGetDto.originalName ) } From f48f4dc0427534db7cf9198a3e15fd233aea5923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 20 Nov 2025 17:45:25 +0900 Subject: [PATCH 427/470] =?UTF-8?q?refactor=20:=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EC=9A=A9=20=EC=84=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9B=90=EB=B3=B8=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85=20=EB=B3=B4=EC=9D=B4=EA=B2=8C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?#153?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/session/dto/SessionImageDto.kt | 4 +++- .../backend/domain/session/facade/SessionFacade.kt | 12 +++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/session/dto/SessionImageDto.kt b/src/main/kotlin/onku/backend/domain/session/dto/SessionImageDto.kt index c376eff..6cfe8f6 100644 --- a/src/main/kotlin/onku/backend/domain/session/dto/SessionImageDto.kt +++ b/src/main/kotlin/onku/backend/domain/session/dto/SessionImageDto.kt @@ -6,5 +6,7 @@ data class SessionImageDto ( @Schema(description = "세션 이미지 ID", example = "1") val sessionImageId : Long, @Schema(description = "세션 이미지 PreSignedUrl", example = "https://~~") - val sessionImagePreSignedUrl : String + 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/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index 6417a3c..d62f358 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -98,14 +98,15 @@ class SessionFacade( val session = sessionService.getByDetailIdFetchDetail(detailId) val detail = session.sessionDetail!! val imageDtos = images.map { image -> - val preSignedUrl = s3Service.getGetS3Url( + val preSignedDto = s3Service.getGetS3Url( memberId = 0, key = image.url - ).preSignedUrl + ) SessionImageDto( sessionImageId = image.id!!, - sessionImagePreSignedUrl = preSignedUrl + sessionImagePreSignedUrl = preSignedDto.preSignedUrl, + sessionOriginalFileName = preSignedDto.originalName ) } @@ -135,10 +136,11 @@ class SessionFacade( // Presigned URL 생성 val imageDtos = images.map { img -> - val presign = s3Service.getGetS3Url(0L, img.url) + val preSignDto = s3Service.getGetS3Url(0L, img.url) SessionImageDto( sessionImageId = img.id!!, - sessionImagePreSignedUrl = presign.preSignedUrl + sessionImagePreSignedUrl = preSignDto.preSignedUrl, + sessionOriginalFileName = preSignDto.originalName ) } From 81fa1f6c75b2d312c30e558930a599a2e05c190c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 21 Nov 2025 14:57:49 +0900 Subject: [PATCH 428/470] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=88=98=EC=A0=95=20=EC=9D=91=EB=8B=B5=20dto=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#155?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/SessionTimeResetResponse.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/kotlin/onku/backend/domain/session/dto/response/SessionTimeResetResponse.kt 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? +) From ec09fc2d8a00642216d69a3bcf201c825a288746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 21 Nov 2025 14:59:00 +0900 Subject: [PATCH 429/470] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84,=20finalize=20=EC=97=AC=EB=B6=80=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20#155?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/facade/SessionFacade.kt | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt index d62f358..059fdae 100644 --- a/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt +++ b/src/main/kotlin/onku/backend/domain/session/facade/SessionFacade.kt @@ -21,7 +21,10 @@ 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( @@ -29,7 +32,8 @@ class SessionFacade( private val sessionDetailService: SessionDetailService, private val sessionImageService : SessionImageService, private val s3Service: S3Service, - private val sessionNoticeService: SessionNoticeService + private val sessionNoticeService: SessionNoticeService, + private val clock: Clock = Clock.system(ZoneId.of("Asia/Seoul")), ) { fun showSessionAboutAbsence(): List { return sessionService.getUpcomingSessionsForAbsence() @@ -172,4 +176,30 @@ class SessionFacade( 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 From c912f9abf1f8907c4cf678d5c61bfd1a4ec13d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 21 Nov 2025 14:59:44 +0900 Subject: [PATCH 430/470] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=88=98=EC=A0=95=20API=20=EC=B6=94=EA=B0=80=20#15?= =?UTF-8?q?5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/user/SessionController.kt | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) 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 index 79b10c2..4311319 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt @@ -2,17 +2,11 @@ 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.GetSessionNoticeResponse -import onku.backend.domain.session.dto.response.SessionCardInfo -import onku.backend.domain.session.dto.response.SessionAboutAbsenceResponse -import onku.backend.domain.session.dto.response.ThisWeekSessionInfo +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.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/session") @@ -58,4 +52,22 @@ class SessionController( ) : ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.getSessionNotice(sessionId))) } + + @PatchMapping("/{sessionId}/time") + @Operation( + summary = "출석체크가 가능하도록 세션 시간 수정 [TEMP]", + description = """ + 1. Try it out 버튼 클릭 + 2. (금주 세션 id를 모른다면 GET /api/v1/session/this-week 에서 sessionId 확인!) + 3. 수정하고자 하는 세션 id 입력 + 4. Execute 버튼 눌러서 요청 보내기 + 5. 완료! 현재 시각 기준 20분 이후까지 출석이 가능하도록 세션 시간 및 기타 세션 정보들이 설정됨 + """ + ) + fun resetSessionTime( + @PathVariable sessionId: Long + ): ResponseEntity> { + val body = sessionFacade.resetSessionTime(sessionId) + return ResponseEntity.ok(SuccessResponse.ok(body)) + } } \ No newline at end of file From d7418955cf0d32b2e67458a7d7b67f58e5f5fad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 21 Nov 2025 22:47:52 +0900 Subject: [PATCH 431/470] =?UTF-8?q?feat:=20email=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94=20=EC=B6=94=EA=B0=80=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/dto/MemberProfileResponse.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt index 1c5c711..5a941d2 100644 --- a/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt +++ b/src/main/kotlin/onku/backend/domain/member/dto/MemberProfileResponse.kt @@ -1,10 +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? ) From e81d579fbebac9cfe3762149d4b46035e7b776de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 21 Nov 2025 22:48:21 +0900 Subject: [PATCH 432/470] =?UTF-8?q?feat:=20dto=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20email=20=EA=B0=92=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/member/service/MemberProfileService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt index 87e1a4f..4d3514d 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberProfileService.kt @@ -88,6 +88,7 @@ class MemberProfileService( val url = key?.let { s3Service.getGetS3Url(member.id!!, it).preSignedUrl } return MemberProfileResponse( + email = member.email, name = profile.name, part = profile.part, totalPoints = total, From 0f13d5d311b640a57e58b3ff10dea0993d212980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Fri, 21 Nov 2025 22:48:37 +0900 Subject: [PATCH 433/470] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EC=88=98=EC=A0=95=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/controller/user/MemberUserController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index cd2b35d..b26204f 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt @@ -40,7 +40,7 @@ class MemberUserController( @GetMapping("/profile/summary") @Operation( summary = "내 프로필 요약 조회", - description = "현재 로그인한 회원의 이름(name), 파트(part), 상벌점 총합(totalPoints), 프로필 이미지(profileImage) 반환" + description = "현재 로그인한 회원의 이름(name), 파트(part), 상벌점 총합(totalPoints), 프로필 이미지(profileImage), 이메일(email) 반환" ) fun getMyProfileSummary( @CurrentMember member: Member From 3a9f14047e61accd136455d13f5888ff727076dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Sat, 22 Nov 2025 10:24:34 +0900 Subject: [PATCH 434/470] =?UTF-8?q?chore:=20=EC=A0=84=EC=8B=9C=ED=9A=8C?= =?UTF-8?q?=EC=9A=A9=20temp=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=9C=ED=81=90=EB=A6=AC=ED=8B=B0=20permitAll?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/global/auth/config/SecurityConfig.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index 9abf22f..bd406dd 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -23,7 +23,8 @@ class SecurityConfig( "/v3/api-docs/**", "/health", "/actuator/health", - "/test/push/**" + "/test/push/**", + "/api/v1/session/{sessionId}/time" ) private val ALLOWED_POST = arrayOf( "/api/v1/auth/kakao", From cdfcda3178ed5c4759a06e3cd876909d1db534d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sun, 23 Nov 2025 19:41:49 +0900 Subject: [PATCH 435/470] =?UTF-8?q?test=20:=20absence=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../absence/service/AbsenceServiceTest.kt | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt diff --git a/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt b/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt new file mode 100644 index 0000000..9441c3b --- /dev/null +++ b/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt @@ -0,0 +1,168 @@ +package onku.backend.absence.service + +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import onku.backend.domain.absence.AbsenceReport +import onku.backend.domain.absence.AbsenceReportErrorCode +import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.absence.enums.AbsenceSubmitType +import onku.backend.domain.absence.repository.AbsenceReportRepository +import onku.backend.domain.absence.repository.projection.GetMyAbsenceReportView +import onku.backend.domain.absence.service.AbsenceService +import onku.backend.domain.member.Member +import onku.backend.domain.session.Session +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import java.time.LocalDateTime +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class AbsenceServiceTest { + + @MockK + lateinit var absenceReportRepository: AbsenceReportRepository + + lateinit var absenceService: AbsenceService + + @BeforeEach + fun setUp() { + absenceService = AbsenceService(absenceReportRepository) + } + + private val member = mockk(relaxed = true) + private val session = mockk(relaxed = true) + private val fileKey = "test-file-key" + + // ========================== + // 1) 새로운 결석신고 생성 테스트 + // ========================== + @Test + fun `submitAbsenceReport - id가 없으면 새로 생성해서 save 한다`() { + // given + val request = SubmitAbsenceReportRequest( + absenceReportId = null, + sessionId = 1L, + submitType = AbsenceSubmitType.ABSENT, + reason = "테스트 이유", + fileName = fileKey, + null, + null + ) + + mockkObject(AbsenceReport) // companion object mock + val createdReport = mockk() + every { + AbsenceReport.createAbsenceReport( + member = member, + session = session, + submitAbsenceReportRequest = request, + fileKey = fileKey + ) + } returns createdReport + + every { absenceReportRepository.save(createdReport) } returns createdReport + + // when + absenceService.submitAbsenceReport(member, request, fileKey, session) + + // then + verify(exactly = 1) { + AbsenceReport.createAbsenceReport(member, session, request, fileKey) + } + verify(exactly = 1) { absenceReportRepository.save(createdReport) } + } + + // ========================== + // 2) 기존 결석신고 수정 테스트 + // ========================== + @Test + fun `submitAbsenceReport - id가 있으면 기존 엔티티 update 후 save 한다`() { + // given + val request = SubmitAbsenceReportRequest( + absenceReportId = 1L, + sessionId = 1L, + submitType = AbsenceSubmitType.ABSENT, + reason = "테스트 이유", + fileName = fileKey, + null, + null + ) + + val existingReport = mockk(relaxed = true) + + every { absenceReportRepository.findById(1L) } returns java.util.Optional.of(existingReport) + every { absenceReportRepository.save(existingReport) } returns existingReport + + // when + absenceService.submitAbsenceReport(member, request, fileKey, session) + + // then + verify(exactly = 1) { + existingReport.updateAbsenceReport(request, fileKey, session) + } + verify(exactly = 1) { absenceReportRepository.save(existingReport) } + } + + // =================================== + // 3) getMyAbsenceReports 매핑 테스트 + // =================================== + @Test + fun `getMyAbsenceReports - projection 리스트를 응답 DTO로 잘 매핑한다`() { + val projection = mockk() + every { projection.getAbsenceReportId() } returns 10L + every { projection.getAbsenceSubmitType() } returns AbsenceSubmitType.LATE + every { projection.getAbsenceReportApproval() } returns AbsenceReportApproval.APPROVED + val now = LocalDateTime.now() + every { projection.getSubmitDateTime() } returns now + every { projection.getSessionTitle() } returns "테스트 세션" + every { projection.getSessionStartDateTime() } returns now.plusDays(1).toLocalDate() + + every { absenceReportRepository.findMyAbsenceReports(member) } returns listOf(projection) + + // when + val result = absenceService.getMyAbsenceReports(member) + + // then + assertEquals(1, result.size) + val dto = result[0] + assertEquals(10L, dto.absenceReportId) + assertEquals(AbsenceSubmitType.LATE, dto.absenceType) + assertEquals(AbsenceReportApproval.APPROVED, dto.absenceReportApproval) + assertEquals("테스트 세션", dto.sessionTitle) + } + + // ========================== + // 4) getById 성공 케이스 + // ========================== + @Test + fun `getById - 존재하면 엔티티를 리턴한다`() { + val report = mockk() + every { absenceReportRepository.findById(1L) } returns java.util.Optional.of(report) + + val result = absenceService.getById(1L) + + assertSame(report, result) + } + + // ========================== + // 5) getById 실패 케이스 + // ========================== + @Test + fun `getById - 없으면 CustomException을 던진다`() { + every { absenceReportRepository.findById(999L) } returns java.util.Optional.empty() + + val ex = assertThrows { + absenceService.getById(999L) + } + assertEquals(AbsenceReportErrorCode.ABSENCE_REPORT_NOT_FOUND, ex.errorCode) + } +} \ No newline at end of file From 43e3f9d9ba551232dd2326b3b30f42815f2f8ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sun, 23 Nov 2025 19:42:12 +0900 Subject: [PATCH 436/470] =?UTF-8?q?build=20:=20test=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 37b5b7f..1314162 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -53,6 +53,8 @@ dependencies { 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") } kotlin { From 84d0a373ce7f433b03d0bf93359b9b99cec176de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sun, 23 Nov 2025 22:44:16 +0900 Subject: [PATCH 437/470] =?UTF-8?q?test=20:=20=EC=B6=9C=EC=84=9D=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AttendanceFinalizeServiceTest.kt | 112 +++++++ .../service/AttendanceServiceTest.kt | 281 ++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt create mode 100644 src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt diff --git a/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt b/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt new file mode 100644 index 0000000..6a4746d --- /dev/null +++ b/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt @@ -0,0 +1,112 @@ +package onku.backend.attendance.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import jakarta.persistence.EntityManager +import onku.backend.domain.absence.repository.AbsenceReportRepository +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.attendance.service.AttendanceFinalizeService +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.util.SessionTimeUtil +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import java.time.Clock +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class AttendanceFinalizeServiceTest { + + @MockK + lateinit var sessionRepository: SessionRepository + + @MockK + lateinit var attendanceRepository: AttendanceRepository + + @MockK + lateinit var absenceReportRepository: AbsenceReportRepository + + @MockK + lateinit var memberRepository: MemberRepository + + @MockK + lateinit var memberPointHistoryRepository: MemberPointHistoryRepository + + @MockK + lateinit var em: EntityManager + + // Clock은 실제 fixed 인스턴스로 사용 + lateinit var clock: Clock + + lateinit var attendanceFinalizeService: AttendanceFinalizeService + + @BeforeEach + fun setUp() { + clock = Clock.fixed( + Instant.parse("2025-01-01T09:00:00Z"), + ZoneId.of("Asia/Seoul") + ) + + attendanceFinalizeService = AttendanceFinalizeService( + sessionRepository = sessionRepository, + attendanceRepository = attendanceRepository, + absenceReportRepository = absenceReportRepository, + memberRepository = memberRepository, + memberPointHistoryRepository = memberPointHistoryRepository, + em = em, + clock = clock + ) + + mockkStatic(SessionTimeUtil::class) + every { SessionTimeUtil.startDateTime(any()) } returns LocalDateTime.of(2025, 1, 1, 10, 0) + } + + // 공통으로 쓸 session mock + private fun createSession( + id: Long = 1L, + week: Long = 1L, + finalized: Boolean = false + ): Session { + val session = mockk(relaxed = true) + every { session.id } returns id + every { session.week } returns week + every { session.attendanceFinalized } returns finalized + every { session.attendanceFinalized = any() } just Runs + every { session.attendanceFinalizedAt = any() } just Runs + return session + } + + @Test + fun `finalizeSession - 이미 final 되었다면 바로 return`() { + val session = createSession(finalized = true) + every { sessionRepository.findById(1L) } returns java.util.Optional.of(session) + + attendanceFinalizeService.finalizeSession(1L) + + verify(exactly = 0) { memberRepository.findApprovedMemberIds() } + verify(exactly = 0) { attendanceRepository.findMemberIdsBySessionId(any()) } + verify(exactly = 0) { absenceReportRepository.findReportsBySessionAndMembers(any(), any()) } + } + + @Test + fun `finalizeSession - approved member가 없으면 finalize만 한다`() { + val session = createSession(finalized = false) + + every { sessionRepository.findById(1L) } returns java.util.Optional.of(session) + every { memberRepository.findApprovedMemberIds() } returns emptyList() + + attendanceFinalizeService.finalizeSession(1L) + + verify { session.attendanceFinalized = true } + verify { session.attendanceFinalizedAt = LocalDateTime.now(clock) } + verify(exactly = 0) { attendanceRepository.findMemberIdsBySessionId(any()) } + verify(exactly = 0) { absenceReportRepository.findReportsBySessionAndMembers(any(), any()) } + } + +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt b/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt new file mode 100644 index 0000000..f947968 --- /dev/null +++ b/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt @@ -0,0 +1,281 @@ +package onku.backend.attendance.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import jakarta.persistence.EntityManager +import onku.backend.domain.attendance.AttendanceErrorCode +import onku.backend.domain.attendance.AttendancePolicy +import onku.backend.domain.attendance.dto.AttendanceAvailabilityResponse +import onku.backend.domain.attendance.dto.AttendanceTokenCore +import onku.backend.domain.attendance.enums.AttendanceAvailabilityReason +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.attendance.service.AttendanceService +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.util.SessionTimeUtil +import onku.backend.global.exception.CustomException +import onku.backend.global.redis.cache.AttendanceTokenCache +import onku.backend.global.redis.dto.TokenData +import onku.backend.global.util.TokenGenerator +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import java.time.Clock +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class AttendanceServiceTest { + @MockK(relaxUnitFun = true) lateinit var tokenCache: AttendanceTokenCache + @MockK lateinit var sessionRepository: SessionRepository + @MockK lateinit var attendanceRepository: AttendanceRepository + @MockK lateinit var memberProfileRepository: MemberProfileRepository + @MockK lateinit var memberPointHistoryRepository: MemberPointHistoryRepository + @MockK lateinit var tokenGenerator: TokenGenerator + @MockK lateinit var em: EntityManager + + + lateinit var clock: Clock + lateinit var attendanceService: AttendanceService + + @BeforeEach + fun setUp() { + // now = 2025-01-01T10:00:00 in Asia/Seoul + clock = Clock.fixed( + Instant.parse("2025-01-01T01:00:00Z"), + ZoneId.of("Asia/Seoul") + ) + + attendanceService = AttendanceService( + tokenCache = tokenCache, + sessionRepository = sessionRepository, + attendanceRepository = attendanceRepository, + memberProfileRepository = memberProfileRepository, + memberPointHistoryRepository = memberPointHistoryRepository, + tokenGenerator = tokenGenerator, + em = em, + clock = clock + ) + + mockkStatic(SessionTimeUtil::class) + } + + private fun createMember(id: Long = 1L): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + return m + } + + private fun createSession( + id: Long = 10L, + week: Long = 3L, + isHoliday: Boolean = false + ): Session { + val s = mockk(relaxed = true) + every { s.id } returns id + every { s.week } returns week + every { s.isHoliday } returns isHoliday + return s + } + + private fun stubMemberProfile(memberId: Long, name: String = "테스트유저") { + val profile = mockk() + every { profile.name } returns name + every { memberProfileRepository.findById(memberId) } returns Optional.of(profile) + } + + // ========================== + // 1) issueAttendanceTokenFor + // ========================== + @Test + fun `issueAttendanceTokenFor - 토큰 발급 및 캐시에 저장`() { + val member = createMember(1L) + val now = LocalDateTime.now(clock) + val ttl = AttendancePolicy.TOKEN_TTL_SECONDS + val fakeToken = "opaque-token-123" + + every { tokenGenerator.generateOpaqueToken() } returns fakeToken + + stubMemberProfile(member.id!!) + + val result: AttendanceTokenCore = attendanceService.issueAttendanceTokenFor(member) + + assertEquals(fakeToken, result.token) + assertEquals(now.plusSeconds(ttl), result.expAt) + + verify { + tokenCache.putAsActiveSingle( + 1L, + "opaque-token-123", + now, + now.plusSeconds(ttl), + ttl, + null + ) + } + } + + // ========================== + // 2) scanAndRecordBy - 정상 출석 기록 + // ========================== + @Test + fun `scanAndRecordBy - 정상 출석 기록 및 포인트 적립`() { + val admin = createMember(id = 99L) + val member = createMember(id = 1L) + val token = "token-abc" + + val now = LocalDateTime.now(clock) + val session = createSession(id = 10L, week = 2L, isHoliday = false) + + // 세션 열려있음 (findOpenSession 내부: findOpenWindow(startBound, now)) + every { + sessionRepository.findOpenWindow(any(), now) + } returns listOf(session) + + // 👉 TokenData 실제 인스턴스 생성 + val peekResult = TokenData( + memberId = member.id!!, + issuedAt = now.minusSeconds(10), + expAt = now.plusSeconds(100), + used = false + ) + + // tokenCache.peek 는 이 TokenData 리턴 + every { tokenCache.peek(token) } returns peekResult + + // 아직 출석 안 되어 있음 + every { + attendanceRepository.existsBySessionIdAndMemberId(any(), any()) + } returns false + + // 토큰 소비 시에도 같은 TokenData 리턴 + every { tokenCache.consumeToken(token, any()) } returns peekResult + + // SessionTimeUtil.startDateTime(session) mock + mockkObject(SessionTimeUtil) + every { SessionTimeUtil.startDateTime(session) } returns now + + // insertOnly: 어떤 값이 오든 1 리턴 + every { + attendanceRepository.insertOnly( + any(), any(), any(), any(), any(), any() + ) + } returns 1 + + // getReference: MemberProxy 역할만 하면 되니까 any()로 + val memberRef = mockk() + every { em.getReference(Member::class.java, any()) } returns memberRef + + every { memberPointHistoryRepository.save(any()) } answers { firstArg() } + + // 이번 주 요약은 countGroupedByStatusBetweenDates 를 빈 리스트로만 처리 + every { + attendanceRepository.countGroupedByStatusBetweenDates(any(), any()) + } returns emptyList() + + // 프로필 이름 stub + stubMemberProfile(member.id!!) + + // when + val response = attendanceService.scanAndRecordBy(admin, token) + + // then + assertEquals(member.id!!, response.memberId) + assertEquals(session.id!!, response.sessionId) + assertEquals(AttendancePointType.LATE, response.state) + assertEquals(now, response.scannedAt) + + verify(exactly = 1) { + attendanceRepository.insertOnly( + any(), // sessionId + any(), // memberId + AttendancePointType.LATE.name, + any(), any(), any() + ) + } + + // save는 any()로 한 번 호출됐는지만 검증 + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + } + + // ========================== + // 3) scanAndRecordBy - 세션 안 열렸을 때 예외 + // ========================== + @Test + fun `scanAndRecordBy - 열려있는 세션이 없으면 예외`() { + val admin = createMember(99L) + val token = "token-x" + val now = LocalDateTime.now(clock) + + every { sessionRepository.findOpenWindow(any(), now) } returns emptyList() + + val ex = assertThrows { + attendanceService.scanAndRecordBy(admin, token) + } + assertEquals(AttendanceErrorCode.SESSION_NOT_OPEN, ex.errorCode) + } + + // ========================== + // 4) checkAvailabilityFor + // ========================== + @Test + fun `checkAvailabilityFor - 세션 없으면 available false`() { + val member = createMember(1L) + val now = LocalDateTime.now(clock) + + every { sessionRepository.findOpenWindow(any(), now) } returns emptyList() + + val result: AttendanceAvailabilityResponse = + attendanceService.checkAvailabilityFor(member) + + assertEquals(false, result.available) + assertEquals(AttendanceAvailabilityReason.NO_OPEN_SESSION, result.reason) + } + + @Test + fun `checkAvailabilityFor - 이미 출석했으면 available false`() { + val member = createMember(1L) + val session = createSession(id = 10L) + + // 세션 하나 열려있다고 가정 + every { + sessionRepository.findOpenWindow(any(), any()) + } returns listOf(session) + + // 해당 세션에 이미 출석 기록 있음 + every { + attendanceRepository.existsBySessionIdAndMemberId(any(), any()) + } returns true + + val result = attendanceService.checkAvailabilityFor(member) + + assertEquals(false, result.available) + assertEquals(AttendanceAvailabilityReason.ALREADY_RECORDED, result.reason) + } + + @Test + fun `checkAvailabilityFor - 출석 가능`() { + val member = createMember(1L) + val now = LocalDateTime.now(clock) + val session = createSession(id = 10L) + + every { sessionRepository.findOpenWindow(any(), now) } returns listOf(session) + every { attendanceRepository.existsBySessionIdAndMemberId(any(), any()) } returns false + + val result = attendanceService.checkAvailabilityFor(member) + + assertTrue(result.available) + assertEquals(null, result.reason) + } +} \ No newline at end of file From a8bd7f1b006ed972b45d30cb430ff0e174d9fa82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sun, 23 Nov 2025 22:46:47 +0900 Subject: [PATCH 438/470] =?UTF-8?q?refactor=20:=20=EC=95=88=20=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=9C=EA=B1=B0=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/domain/session/service/SessionNoticeService.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/session/service/SessionNoticeService.kt b/src/main/kotlin/onku/backend/domain/session/service/SessionNoticeService.kt index 97f7494..ba4da68 100644 --- a/src/main/kotlin/onku/backend/domain/session/service/SessionNoticeService.kt +++ b/src/main/kotlin/onku/backend/domain/session/service/SessionNoticeService.kt @@ -7,7 +7,6 @@ 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 onku.backend.global.s3.service.S3Service import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -15,7 +14,6 @@ import org.springframework.transaction.annotation.Transactional class SessionNoticeService( private val sessionRepository: SessionRepository, private val sessionImageRepository: SessionImageRepository, - private val s3Service: S3Service ) { @Transactional(readOnly = true) fun getSessionWithImages(sessionId: Long): Triple> { From 9eec30877cd829b25f4274f2cfa1be7a03df1e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 24 Nov 2025 12:42:55 +0900 Subject: [PATCH 439/470] =?UTF-8?q?test=20:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SessionDetailServiceTest.kt | 222 ++++++++++ .../service/SessionImageServiceTest.kt | 150 +++++++ .../service/SessionNoticeServiceTest.kt | 126 ++++++ .../session/service/SessionServiceTest.kt | 408 ++++++++++++++++++ 4 files changed, 906 insertions(+) create mode 100644 src/test/kotlin/onku/backend/session/service/SessionDetailServiceTest.kt create mode 100644 src/test/kotlin/onku/backend/session/service/SessionImageServiceTest.kt create mode 100644 src/test/kotlin/onku/backend/session/service/SessionNoticeServiceTest.kt create mode 100644 src/test/kotlin/onku/backend/session/service/SessionServiceTest.kt diff --git a/src/test/kotlin/onku/backend/session/service/SessionDetailServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionDetailServiceTest.kt new file mode 100644 index 0000000..e18a5e9 --- /dev/null +++ b/src/test/kotlin/onku/backend/session/service/SessionDetailServiceTest.kt @@ -0,0 +1,222 @@ +package onku.backend.session.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import onku.backend.domain.attendance.finalize.FinalizeEvent +import onku.backend.domain.session.Session +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.dto.request.UpsertSessionDetailRequest +import onku.backend.domain.session.repository.SessionDetailRepository +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.service.SessionDetailService +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.context.ApplicationEventPublisher +import java.time.LocalTime +import java.util.* +import kotlin.test.Test +@ExtendWith(MockKExtension::class) +class SessionDetailServiceTest { + @MockK lateinit var sessionDetailRepository: SessionDetailRepository + @MockK lateinit var sessionRepository: SessionRepository + @MockK(relaxUnitFun = true) lateinit var applicationEventPublisher: ApplicationEventPublisher + + lateinit var service: SessionDetailService + + @BeforeEach + fun setUp() { + service = SessionDetailService( + sessionDetailRepository = sessionDetailRepository, + sessionRepository = sessionRepository, + applicationEventPublisher = applicationEventPublisher + ) + clearMocks(sessionDetailRepository, sessionRepository, applicationEventPublisher) + + } + + private fun createSession(id: Long = 10L): Session { + val s = mockk(relaxed = true) + every { s.id } returns id + every { s.sessionDetail = any() } just Runs + return s + } + + // ========================== + // 1) sessionDetailId 존재 → 기존 상세 수정 + // ========================== + @Test + fun `upsertSessionDetail - 기존 상세가 있으면 수정한다`() { + val session = createSession(id = 100L) + + val existingDetail = SessionDetail( + id = 1L, + place = "기존 장소", + startTime = LocalTime.of(9, 0), + endTime = LocalTime.of(11, 0), + content = "기존 내용" + ) + + val request = UpsertSessionDetailRequest( + sessionId = 10L, + sessionDetailId = 1L, + place = "새 장소", + startTime = LocalTime.of(10, 0), + endTime = LocalTime.of(12, 0), + content = "새 내용" + ) + + every { sessionDetailRepository.findById(1L) } returns Optional.of(existingDetail) + every { sessionRepository.save(session) } returns session + // Unit 메서드라서 just Runs (혹은 @MockK(relaxUnitFun = true)면 없어도 됨) + every { applicationEventPublisher.publishEvent(any()) } just Runs + + // when + val returnedId = service.upsertSessionDetail(session, request) + + // then - detail 변경 확인 + assertEquals(1L, returnedId) + assertEquals("새 장소", existingDetail.place) + assertEquals(LocalTime.of(10, 0), existingDetail.startTime) + assertEquals(LocalTime.of(12, 0), existingDetail.endTime) + assertEquals("새 내용", existingDetail.content) + + // 세션에 detail 연결 + 세션 저장 + verify { session.sessionDetail = existingDetail } + verify { sessionRepository.save(session) } + + // FinalizeEvent 발행됐는지만 확인 (runAt까지는 굳이 안 봐도 됨) + val eventSlot = slot() + verify { + applicationEventPublisher.publishEvent(capture(eventSlot)) + } + assertEquals(100L, eventSlot.captured.sessionId) // 세션 ID만 검증 + // runAt은 null 아니면 됐다 정도만 보고 싶으면: + // assertNotNull(eventSlot.captured.runAt) + } + + // ========================== + // 2) sessionDetailId 없음 → 새 상세 생성 + // ========================== + @Test + fun `upsertSessionDetail - sessionDetailId가 없으면 새로 생성한다`() { + val session = createSession(id = 200L) + + val request = UpsertSessionDetailRequest( + sessionId = 10L, + sessionDetailId = null, + place = "강의실 101", + startTime = LocalTime.of(13, 0), + endTime = LocalTime.of(15, 0), + content = "세션 내용" + ) + + val savedDetail = SessionDetail( + id = 10L, + place = request.place, + startTime = request.startTime, + endTime = request.endTime, + content = request.content + ) + + every { sessionDetailRepository.save(any()) } returns savedDetail + every { sessionRepository.save(session) } returns session + every { applicationEventPublisher.publishEvent(any()) } just Runs + + // when + val returnedId = service.upsertSessionDetail(session, request) + + // then + assertEquals(10L, returnedId) + + // 새 detail이 기대한 값으로 save 되었는지 검증 + verify { + sessionDetailRepository.save( + match { + it.place == "강의실 101" && + it.startTime == LocalTime.of(13, 0) && + it.endTime == LocalTime.of(15, 0) && + it.content == "세션 내용" + } + ) + } + + // 세션에 detail 연결 + 세션 저장 + verify { session.sessionDetail = savedDetail } + verify { sessionRepository.save(session) } + + // FinalizeEvent 발행 확인 (sessionId만 체크, runAt은 null 아님만 확인) + val eventSlot = slot() + verify { applicationEventPublisher.publishEvent(capture(eventSlot)) } + + assertEquals(200L, eventSlot.captured.sessionId) + assertNotNull(eventSlot.captured.runAt) + } + + // ========================== + // 3) sessionDetailId 있는데 DB에 없음 → 예외 + // ========================== + @Test + fun `upsertSessionDetail - 기존 상세 없으면 예외`() { + val session = createSession(id = 300L) + + val request = UpsertSessionDetailRequest( + sessionId = 10L, + sessionDetailId = 999L, + place = "어딘가", + startTime = LocalTime.of(10, 0), + endTime = LocalTime.of(11, 0), + content = "내용" + ) + + every { sessionDetailRepository.findById(999L) } returns Optional.empty() + + val ex = assertThrows { + service.upsertSessionDetail(session, request) + } + + assertEquals(SessionErrorCode.SESSION_DETAIL_NOT_FOUND, ex.errorCode) + verify(exactly = 0) { sessionRepository.save(any()) } + verify(exactly = 0) { applicationEventPublisher.publishEvent(any()) } + } + + // ========================== + // 4) getById - 정상 + // ========================== + @Test + fun `getById - 정상 조회`() { + val detail = SessionDetail( + id = 5L, + place = "강의실", + startTime = LocalTime.of(9, 0), + endTime = LocalTime.of(11, 0), + content = "내용" + ) + + every { sessionDetailRepository.findById(5L) } returns Optional.of(detail) + + val found = service.getById(5L) + + assertEquals(5L, found.id) + assertEquals("강의실", found.place) + } + + // ========================== + // 5) getById - 못 찾으면 예외 + // ========================== + @Test + fun `getById - 상세 없으면 예외`() { + every { sessionDetailRepository.findById(100L) } returns Optional.empty() + + val ex = assertThrows { + service.getById(100L) + } + + assertEquals(SessionErrorCode.SESSION_DETAIL_NOT_FOUND, ex.errorCode) + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/session/service/SessionImageServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionImageServiceTest.kt new file mode 100644 index 0000000..2824d0a --- /dev/null +++ b/src/test/kotlin/onku/backend/session/service/SessionImageServiceTest.kt @@ -0,0 +1,150 @@ +package onku.backend.session.service + +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.SessionImage +import onku.backend.domain.session.repository.SessionImageRepository +import onku.backend.domain.session.service.SessionImageService +import onku.backend.global.exception.CustomException +import onku.backend.global.s3.dto.GetS3UrlDto +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import java.util.* +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class SessionImageServiceTest { + + @MockK(relaxUnitFun = true) + lateinit var sessionImageRepository: SessionImageRepository + + lateinit var sessionImageService: SessionImageService + + @BeforeEach + fun setUp() { + sessionImageService = SessionImageService(sessionImageRepository) + } + + private fun createSessionDetail(id: Long = 1L): SessionDetail { + // 진짜 엔티티를 써도 되고 mockk로 써도 됨. 여기선 간단히 mock 사용. + val detail = mockk(relaxed = true) + every { detail.id } returns id + return detail + } + + // ========================== + // uploadImages + // ========================== + @Test + fun `uploadImages - SessionDetail과 url로 SessionImage를 생성하고 저장한다`() { + val detail = createSessionDetail(1L) + + val preSignedImages = listOf( + GetS3UrlDto(key = "SESSION/1/A/img1.png", preSignedUrl = "https://...1", originalName = "img1.png"), + GetS3UrlDto(key = "SESSION/1/A/img2.png", preSignedUrl = "https://...2", originalName = "img2.png"), + ) + + // saveAll이 들어온 그대로 돌려주도록 설정 (엔티티 equals 안 맞아도 문제 없게) + every { sessionImageRepository.saveAll(any>()) } answers { + firstArg>().toList() + } + + // when + val result: List = sessionImageService.uploadImages(detail, preSignedImages) + + // then: repo.saveAll이 SessionImage 리스트로 호출됐는지 + verify (exactly = 1) { + sessionImageRepository.saveAll( + match> { images -> + images.count() == 2 && + images.all { it.sessionDetail == detail } && + images.map { it.url }.toSet() == setOf( + "SESSION/1/A/img1.png", + "SESSION/1/A/img2.png" + ) + } + ) + } + + assertEquals(2, result.size) + assertEquals("SESSION/1/A/img1.png", result[0].url) + assertEquals("SESSION/1/A/img2.png", result[1].url) + assertEquals(detail, result[0].sessionDetail) + } + + // ========================== + // deleteImage + // ========================== + @Test + fun `deleteImage - id 기반으로 삭제 요청을 보낸다`() { + val imageId = 10L + + // when + sessionImageService.deleteImage(imageId) + + // then + verify(exactly = 1) { sessionImageRepository.deleteById(imageId) } + } + + // ========================== + // getById + // ========================== + @Test + fun `getById - 이미지가 존재하면 반환한다`() { + val imageId = 5L + val detail = createSessionDetail(1L) + val image = SessionImage( + id = imageId, + sessionDetail = detail, + url = "SESSION/1/A/img.png" + ) + + every { sessionImageRepository.findById(imageId) } returns Optional.of(image) + + val result = sessionImageService.getById(imageId) + + assertSame(image, result) + } + + @Test + fun `getById - 이미지가 없으면 예외를 던진다`() { + val imageId = 999L + every { sessionImageRepository.findById(imageId) } returns Optional.empty() + + val ex = assertThrows { + sessionImageService.getById(imageId) + } + + assertEquals(SessionErrorCode.SESSION_IMAGE_NOT_FOUND, ex.errorCode) + } + + // ========================== + // findAllBySessionDetailId + // ========================== + @Test + fun `findAllBySessionDetailId - detailId로 모든 이미지를 조회한다`() { + val detailId = 3L + val detail = createSessionDetail(detailId) + + val images = listOf( + SessionImage(id = 1L, sessionDetail = detail, url = "SESSION/3/A/img1.png"), + SessionImage(id = 2L, sessionDetail = detail, url = "SESSION/3/A/img2.png"), + ) + + every { sessionImageRepository.findAllBySessionDetailId(detailId) } returns images + + val result = sessionImageService.findAllBySessionDetailId(detailId) + + assertEquals(2, result.size) + assertEquals(images, result) + verify(exactly = 1) { sessionImageRepository.findAllBySessionDetailId(detailId) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/session/service/SessionNoticeServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionNoticeServiceTest.kt new file mode 100644 index 0000000..ef1166e --- /dev/null +++ b/src/test/kotlin/onku/backend/session/service/SessionNoticeServiceTest.kt @@ -0,0 +1,126 @@ +package onku.backend.session.service + +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import onku.backend.domain.session.Session +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.SessionImage +import onku.backend.domain.session.repository.SessionImageRepository +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.service.SessionNoticeService +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class SessionNoticeServiceTest { + + @MockK lateinit var sessionRepository: SessionRepository + @MockK lateinit var sessionImageRepository: SessionImageRepository + + lateinit var sessionNoticeService: SessionNoticeService + + @BeforeEach + fun setUp() { + sessionNoticeService = SessionNoticeService( + sessionRepository = sessionRepository, + sessionImageRepository = sessionImageRepository + ) + } + + private fun createSession( + id: Long = 1L, + detail: SessionDetail? = null + ): Session { + val s = mockk(relaxed = true) + every { s.id } returns id + every { s.sessionDetail } returns detail + return s + } + + private fun createDetail( + id: Long = 10L + ): SessionDetail { + val d = mockk(relaxed = true) + every { d.id } returns id + return d + } + + // ========================== + // 1) 정상 케이스 + // ========================== + @Test + fun `getSessionWithImages - 세션과 디테일, 이미지가 있으면 Triple을 반환한다`() { + val sessionId = 1L + val detailId = 10L + + val detail = createDetail(detailId) + val session = createSession(id = sessionId, detail = detail) + + val images = listOf( + SessionImage(id = 100L, sessionDetail = detail, url = "SESSION/1/A/img1.png"), + SessionImage(id = 101L, sessionDetail = detail, url = "SESSION/1/A/img2.png"), + ) + + every { sessionRepository.findWithDetail(sessionId) } returns session + every { sessionImageRepository.findByDetailId(detailId) } returns images + + // when + val (returnedSession, returnedDetail, returnedImages) = + sessionNoticeService.getSessionWithImages(sessionId) + + // then + assertSame(session, returnedSession) + assertSame(detail, returnedDetail) + assertEquals(2, returnedImages.size) + assertEquals(images, returnedImages) + + verify(exactly = 1) { sessionRepository.findWithDetail(sessionId) } + verify(exactly = 1) { sessionImageRepository.findByDetailId(detailId) } + } + + // ========================== + // 2) 세션이 없을 때 + // ========================== + @Test + fun `getSessionWithImages - 세션이 없으면 SESSION_NOT_FOUND 예외`() { + val sessionId = 99L + + every { sessionRepository.findWithDetail(sessionId) } returns null + + val ex = assertThrows { + sessionNoticeService.getSessionWithImages(sessionId) + } + + assertEquals(SessionErrorCode.SESSION_NOT_FOUND, ex.errorCode) + verify(exactly = 1) { sessionRepository.findWithDetail(sessionId) } + verify(exactly = 0) { sessionImageRepository.findByDetailId(any()) } + } + + // ========================== + // 3) 디테일이 없을 때 + // ========================== + @Test + fun `getSessionWithImages - sessionDetail이 없으면 SESSION_DETAIL_NOT_FOUND 예외`() { + val sessionId = 1L + val session = createSession(id = sessionId, detail = null) + + every { sessionRepository.findWithDetail(sessionId) } returns session + + val ex = assertThrows { + sessionNoticeService.getSessionWithImages(sessionId) + } + + assertEquals(SessionErrorCode.SESSION_DETAIL_NOT_FOUND, ex.errorCode) + verify(exactly = 1) { sessionRepository.findWithDetail(sessionId) } + verify(exactly = 0) { sessionImageRepository.findByDetailId(any()) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/session/service/SessionServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionServiceTest.kt new file mode 100644 index 0000000..25d1c04 --- /dev/null +++ b/src/test/kotlin/onku/backend/session/service/SessionServiceTest.kt @@ -0,0 +1,408 @@ +package onku.backend.session.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import onku.backend.domain.absence.repository.AbsenceReportRepository +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.session.Session +import onku.backend.domain.session.SessionDetail +import onku.backend.domain.session.SessionErrorCode +import onku.backend.domain.session.dto.request.SessionSaveRequest +import onku.backend.domain.session.dto.response.ThisWeekSessionInfo +import onku.backend.domain.session.enums.SessionCategory +import onku.backend.domain.session.repository.SessionDetailRepository +import onku.backend.domain.session.repository.SessionImageRepository +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.domain.session.service.SessionService +import onku.backend.domain.session.validator.SessionValidator +import onku.backend.global.exception.CustomException +import onku.backend.global.s3.service.S3Service +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.repository.findByIdOrNull +import java.time.* +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class SessionServiceTest { + + @MockK lateinit var sessionRepository: SessionRepository + @MockK lateinit var sessionValidator: SessionValidator + @MockK lateinit var sessionImageRepository: SessionImageRepository + @MockK lateinit var s3Service: S3Service + @MockK lateinit var attendanceRepository: AttendanceRepository + @MockK lateinit var absenceReportRepository: AbsenceReportRepository + @MockK lateinit var sessionDetailRepository: SessionDetailRepository + + lateinit var clock: Clock + lateinit var sessionService: SessionService + + @BeforeEach + fun setUp() { + clock = Clock.fixed( + Instant.parse("2025-01-01T01:00:00Z"), // Asia/Seoul → 2025-01-01T10:00 + ZoneId.of("Asia/Seoul") + ) + + sessionService = SessionService( + sessionRepository = sessionRepository, + sessionValidator = sessionValidator, + sessionImageRepository = sessionImageRepository, + s3Service = s3Service, + clock = clock, + attendanceRepository = attendanceRepository, + absenceReportRepository = absenceReportRepository, + sessionDetailRepository = sessionDetailRepository + ) + } + + private fun createSession( + id: Long = 1L, + title: String = "세션", + startDate: LocalDate = LocalDate.of(2025, 1, 5), + week: Long = 1L, + category: SessionCategory = SessionCategory.MEETUP_PROJECT, + isHoliday: Boolean = false, + detail: SessionDetail? = null + ): Session { + val s = mockk(relaxed = true) + every { s.id } returns id + every { s.title } returns title + every { s.startDate } returns startDate + every { s.week } returns week + every { s.category } returns category + every { s.isHoliday } returns isHoliday + every { s.sessionDetail } returns detail + return s + } + + // ========================== + // 1) getUpcomingSessionsForAbsence + // ========================== + @Test + fun `getUpcomingSessionsForAbsence - 세션 목록과 active 여부를 반환한다`() { + val now = LocalDateTime.now(clock) // 2025-01-01T10:00 + val today = now.toLocalDate() // 2025-01-01 + + val s1 = createSession(id = 1L, title = "1주차", startDate = today, week = 1L) + val s2 = createSession(id = 2L, title = "2주차", startDate = today.plusDays(1), week = 2L) + + every { sessionRepository.findUpcomingSessions(today) } returns listOf(s1, s2) + every { sessionValidator.isImminentSession(s1, now) } returns true + every { sessionValidator.isImminentSession(s2, now) } returns false + + val result = sessionService.getUpcomingSessionsForAbsence() + + assertEquals(2, result.size) + + val r1 = result[0] + assertEquals(1L, r1.sessionId) + assertEquals("1주차", r1.title) + assertEquals(1L, r1.week) + assertEquals(true, r1.active) + + val r2 = result[1] + assertEquals(2L, r2.sessionId) + assertEquals("2주차", r2.title) + assertEquals(2L, r2.week) + assertEquals(false, r2.active) + + verify { sessionRepository.findUpcomingSessions(today) } + verify { sessionValidator.isImminentSession(s1, now) } + verify { sessionValidator.isImminentSession(s2, now) } + } + + // ========================== + // 2) getById + // ========================== + @Test + fun `getById - 세션이 있으면 반환한다`() { + val session = createSession(id = 10L) + every { sessionRepository.findByIdOrNull(10L) } returns session + + val result = sessionService.getById(10L) + + assertSame(session, result) + } + + @Test + fun `getById - 세션이 없으면 예외 던진다`() { + every { sessionRepository.findByIdOrNull(10L) } returns null + + val ex = assertThrows { + sessionService.getById(10L) + } + assertEquals(SessionErrorCode.SESSION_NOT_FOUND, ex.errorCode) + } + + // ========================== + // 3) saveAll + // ========================== + @Test + fun `saveAll - 요청 리스트를 Session 엔티티로 저장한다`() { + val requests = listOf( + SessionSaveRequest( + title = "세션1", + sessionDate = LocalDate.of(2025, 1, 10), + category = SessionCategory.MEETUP_PROJECT, + week = 1L, + isHoliday = false + ), + SessionSaveRequest( + title = "세션2", + sessionDate = LocalDate.of(2025, 1, 17), + category = SessionCategory.MEETUP_PROJECT, + week = 2L, + isHoliday = true + ) + ) + + every { sessionRepository.saveAll(any>()) } answers { + firstArg>().toList() + } + + val result = sessionService.saveAll(requests) + assertTrue(result) + + val slot = slot>() + verify { + sessionRepository.saveAll(capture(slot)) + } + + val saved = slot.captured.toList() + assertEquals(2, saved.size) + assertEquals("세션1", saved[0].title) + assertEquals(LocalDate.of(2025, 1, 10), saved[0].startDate) + assertEquals(1L, saved[0].week) + assertFalse(saved[0].isHoliday) + + assertEquals("세션2", saved[1].title) + assertEquals(LocalDate.of(2025, 1, 17), saved[1].startDate) + assertEquals(2L, saved[1].week) + assertTrue(saved[1].isHoliday) + } + + // ========================== + // 4) getInitialSession + // ========================== + @Test + fun `getInitialSession - 페이지로 세션을 조회하고 DTO로 매핑한다`() { + val s1 = createSession( + id = 1L, + title = "세션1", + startDate = LocalDate.of(2025, 1, 10), + category = SessionCategory.MEETUP_PROJECT, + isHoliday = false, + detail = mockk(relaxed = true) { + every { id } returns 100L + } + ) + val s2 = createSession( + id = 2L, + title = "세션2", + startDate = LocalDate.of(2025, 1, 17), + category = SessionCategory.MEETUP_PROJECT, + isHoliday = true, + detail = null + ) + + val pageable = PageRequest.of(0, 10) + val page: Page = PageImpl(listOf(s1, s2), pageable, 2) + + every { sessionRepository.findAll(pageable) } returns page + + val resultPage = sessionService.getInitialSession(pageable) + + assertEquals(2, resultPage.totalElements) + val dto1 = resultPage.content[0] + assertEquals(1L, dto1.sessionId) + assertEquals(LocalDate.of(2025, 1, 10), dto1.startDate) + assertEquals("세션1", dto1.title) + assertEquals(SessionCategory.MEETUP_PROJECT, dto1.category) + assertEquals(100L, dto1.sessionDetailId) + assertFalse(dto1.isHoliday) + + val dto2 = resultPage.content[1] + assertEquals(2L, dto2.sessionId) + assertEquals(LocalDate.of(2025, 1, 17), dto2.startDate) + assertEquals("세션2", dto2.title) + assertEquals(SessionCategory.MEETUP_PROJECT, dto2.category) + assertNull(dto2.sessionDetailId) + assertTrue(dto2.isHoliday) + } + + // ========================== + // 5) getThisWeekSession + // (날짜 range는 TimeRangeUtil에 맡기고, 여기선 repo 호출 + 매핑만 확인) + // ========================== + @Test + fun `getThisWeekSession - 이번주 세션 정보를 매핑해서 반환한다`() { + val projection = mockk() + every { projection.sessionId } returns 1L + every { projection.sessionDetailId } returns 10L + every { projection.title } returns "이번주 세션" + every { projection.place } returns "강의실 101" + every { projection.startDate } returns LocalDate.of(2025, 1, 5) + every { projection.startTime } returns LocalTime.of(10, 0) + every { projection.endTime } returns LocalTime.of(12, 0) + every { projection.isHoliday } returns false + + every { sessionRepository.findThisWeekSunToSat(any(), any()) } returns listOf(projection) + + val result = sessionService.getThisWeekSession() + + assertEquals(1, result.size) + val dto = result[0] + assertEquals(1L, dto.sessionId) + assertEquals(10L, dto.sessionDetailId) + assertEquals("이번주 세션", dto.title) + assertEquals("강의실 101", dto.place) + assertEquals(LocalDate.of(2025, 1, 5), dto.startDate) + assertEquals(LocalTime.of(10, 0), dto.startTime) + assertEquals(LocalTime.of(12, 0), dto.endTime) + + verify { sessionRepository.findThisWeekSunToSat(any(), any()) } + } + + // ========================== + // 6) getAllSessionsOrderByStartDate + // ========================== + @Test + fun `getAllSessionsOrderByStartDate - 모든 세션을 카드 정보로 반환한다`() { + val s1 = createSession( + id = 1L, + title = "세션1", + startDate = LocalDate.of(2025, 1, 3), + category = SessionCategory.MEETUP_PROJECT, + isHoliday = false + ) + val s2 = createSession( + id = 2L, + title = "세션2", + startDate = LocalDate.of(2025, 1, 10), + category = SessionCategory.MEETUP_PROJECT, + isHoliday = true + ) + + every { sessionRepository.findAllSessionsOrderByStartDate() } returns listOf(s1, s2) + + val result = sessionService.getAllSessionsOrderByStartDate() + + assertEquals(2, result.size) + val card1 = result[0] + assertEquals(1L, card1.sessionId) + assertEquals(SessionCategory.MEETUP_PROJECT, card1.sessionCategory) + assertEquals("세션1", card1.title) + assertEquals(LocalDate.of(2025, 1, 3), card1.startDate) + + val card2 = result[1] + assertEquals(2L, card2.sessionId) + assertEquals(SessionCategory.MEETUP_PROJECT, card2.sessionCategory) + assertEquals("세션2", card2.title) + assertEquals(LocalDate.of(2025, 1, 10), card2.startDate) + } + + // ========================== + // 7) deleteCascade + // ========================== + + @Test + fun `deleteCascade - 세션이 없으면 예외`() { + every { sessionRepository.findWithDetail(1L) } returns null + + val ex = assertThrows { + sessionService.deleteCascade(1L) + } + + assertEquals(SessionErrorCode.SESSION_NOT_FOUND, ex.errorCode) + verify(exactly = 0) { attendanceRepository.deleteAllBySessionId(any()) } + verify(exactly = 0) { sessionRepository.deleteById(any()) } + } + + @Test + fun `deleteCascade - detailId가 없으면 attendance, absence만 지우고 세션 삭제`() { + every { sessionRepository.findWithDetail(1L) } returns createSession(id = 1L) + every { sessionRepository.findDetailIdBySessionId(1L) } returns null + + every { attendanceRepository.deleteAllBySessionId(1L) } returns 1 + every { absenceReportRepository.deleteAllBySessionId(1L) } returns 1 + every { sessionRepository.deleteById(1L) } just Runs + + sessionService.deleteCascade(1L) + + verify { attendanceRepository.deleteAllBySessionId(1L) } + verify { absenceReportRepository.deleteAllBySessionId(1L) } + verify(exactly = 0) { sessionImageRepository.findAllImageKeysByDetailId(any()) } + verify(exactly = 0) { sessionDetailRepository.deleteById(any()) } + verify { sessionRepository.deleteById(1L) } + } + + @Test + fun `deleteCascade - detailId가 있고 이미지가 있으면 S3와 이미지, detail, session을 차례로 삭제`() { + val sessionId = 1L + val detailId = 10L + + every { sessionRepository.findWithDetail(sessionId) } returns createSession(id = sessionId) + every { sessionRepository.findDetailIdBySessionId(sessionId) } returns detailId + + every { attendanceRepository.deleteAllBySessionId(sessionId) } returns 1 + every { absenceReportRepository.deleteAllBySessionId(sessionId) } returns 1 + + every { sessionImageRepository.findAllImageKeysByDetailId(detailId) } returns listOf( + "", + "SESSION/1/A/img1.png", + "SESSION/1/A/img2.png" + ) + + every { s3Service.deleteObjectsNow(listOf("SESSION/1/A/img1.png", "SESSION/1/A/img2.png")) } just Runs + every { sessionImageRepository.deleteByDetailIdBulk(detailId) } returns 1 + every { sessionRepository.detachDetailFromSession(sessionId) } returns 1 + every { sessionDetailRepository.deleteById(detailId) } just Runs + every { sessionRepository.deleteById(sessionId) } just Runs + + sessionService.deleteCascade(sessionId) + + verify { attendanceRepository.deleteAllBySessionId(sessionId) } + verify { absenceReportRepository.deleteAllBySessionId(sessionId) } + verify { sessionImageRepository.findAllImageKeysByDetailId(detailId) } + verify { + s3Service.deleteObjectsNow(listOf("SESSION/1/A/img1.png", "SESSION/1/A/img2.png")) + } + verify { sessionImageRepository.deleteByDetailIdBulk(detailId) } + verify { sessionRepository.detachDetailFromSession(sessionId) } + verify { sessionDetailRepository.deleteById(detailId) } + verify { sessionRepository.deleteById(sessionId) } + } + + // ========================== + // 8) getByDetailIdFetchDetail + // ========================== + + @Test + fun `getByDetailIdFetchDetail - 세션이 있으면 반환`() { + val session = createSession(id = 1L) + every { sessionRepository.findByDetailIdFetchDetail(10L) } returns session + + val result = sessionService.getByDetailIdFetchDetail(10L) + + assertSame(session, result) + } + + @Test + fun `getByDetailIdFetchDetail - 세션 없으면 예외`() { + every { sessionRepository.findByDetailIdFetchDetail(10L) } returns null + + val ex = assertThrows { + sessionService.getByDetailIdFetchDetail(10L) + } + + assertEquals(SessionErrorCode.SESSION_NOT_FOUND, ex.errorCode) + } +} \ No newline at end of file From 39d9520e102050c4e3f34c7d2a746803d4fb6a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 24 Nov 2025 19:01:40 +0900 Subject: [PATCH 440/470] =?UTF-8?q?test=20:=20=EC=A0=90=EC=88=98=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminPointCommandServiceTest.kt | 389 ++++++++++++++++++ .../point/service/AdminPointServiceTest.kt | 333 +++++++++++++++ .../service/MemberPointHistoryServiceTest.kt | 265 ++++++++++++ 3 files changed, 987 insertions(+) create mode 100644 src/test/kotlin/onku/backend/point/service/AdminPointCommandServiceTest.kt create mode 100644 src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt create mode 100644 src/test/kotlin/onku/backend/point/service/MemberPointHistoryServiceTest.kt diff --git a/src/test/kotlin/onku/backend/point/service/AdminPointCommandServiceTest.kt b/src/test/kotlin/onku/backend/point/service/AdminPointCommandServiceTest.kt new file mode 100644 index 0000000..48d1436 --- /dev/null +++ b/src/test/kotlin/onku/backend/point/service/AdminPointCommandServiceTest.kt @@ -0,0 +1,389 @@ +package onku.backend.point.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import onku.backend.domain.attendance.Attendance +import onku.backend.domain.attendance.AttendanceErrorCode +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.kupick.Kupick +import onku.backend.domain.kupick.KupickErrorCode +import onku.backend.domain.kupick.repository.KupickRepository +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.point.ManualPoint +import onku.backend.domain.point.dto.StudyPointsResult +import onku.backend.domain.point.repository.ManualPointRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.point.service.AdminPointCommandService +import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import java.time.Clock +import java.time.Instant +import java.time.YearMonth +import java.time.ZoneId +import java.util.* +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@ExtendWith(MockKExtension::class) +class AdminPointCommandServiceTest { + + @MockK lateinit var manualPointRecordRepository: ManualPointRepository + @MockK lateinit var memberRepository: MemberRepository + @MockK lateinit var kupickRepository: KupickRepository + @MockK lateinit var memberPointHistoryRepository: MemberPointHistoryRepository + @MockK lateinit var attendanceRepository: AttendanceRepository + @MockK lateinit var sessionRepository: SessionRepository + + lateinit var clock: Clock + lateinit var service: AdminPointCommandService + + @BeforeEach + fun setUp() { + clock = Clock.fixed( + Instant.parse("2025-01-01T01:00:00Z"), // Asia/Seoul → 10:00 + ZoneId.of("Asia/Seoul") + ) + + service = AdminPointCommandService( + manualPointRecordRepository = manualPointRecordRepository, + memberRepository = memberRepository, + kupickRepository = kupickRepository, + memberPointHistoryRepository = memberPointHistoryRepository, + attendanceRepository = attendanceRepository, + sessionRepository = sessionRepository, + clock = clock + ) + + every { memberPointHistoryRepository.save(any()) } answers { firstArg() } + } + + private fun createMember(id: Long = 1L): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + return m + } + + // ========================== + // updateStudyPoints + // ========================== + + @Test + fun `updateStudyPoints - 기존 레코드 있고 diff가 있으면 history 저장 + 값 갱신`() { + val member = createMember(1L) + val manual = ManualPoint( + member = member, + studyPoints = 10, + kupportersPoints = 0, + memo = null + ) + + every { manualPointRecordRepository.findByMemberId(1L) } returns manual + every { manualPointRecordRepository.save(any()) } answers { firstArg() } + + val result: StudyPointsResult = service.updateStudyPoints(1L, 30) + + assertEquals(1L, result.memberId) + assertEquals(30, result.studyPoints) + assertEquals(30, manual.studyPoints) + + // diff = 20 → history 1번 저장 + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + verify(exactly = 1) { manualPointRecordRepository.save(manual) } + } + + @Test + fun `updateStudyPoints - diff가 0이면 history는 저장하지 않는다`() { + val member = createMember(1L) + val manual = ManualPoint( + member = member, + studyPoints = 20, + kupportersPoints = 0, + memo = null + ) + + every { manualPointRecordRepository.findByMemberId(1L) } returns manual + every { manualPointRecordRepository.save(any()) } answers { firstArg() } + + val result = service.updateStudyPoints(1L, 20) + + assertEquals(20, result.studyPoints) + assertEquals(20, manual.studyPoints) + + // history 저장 X + verify(exactly = 0) { memberPointHistoryRepository.save(any()) } + verify(exactly = 1) { manualPointRecordRepository.save(manual) } + } + + @Test + fun `updateStudyPoints - 기존 레코드 없으면 newManualRecord 생성해서 사용`() { + val member = createMember(1L) + + every { manualPointRecordRepository.findByMemberId(1L) } returns null + every { memberRepository.getReferenceById(1L) } returns member + every { manualPointRecordRepository.save(any()) } answers { firstArg() } + + val result = service.updateStudyPoints(1L, 15) + + assertEquals(1L, result.memberId) + assertEquals(15, result.studyPoints) + + // getReferenceById 통해 ManualPoint 생성 + verify { memberRepository.getReferenceById(1L) } + verify { manualPointRecordRepository.save(any()) } + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } // diff = 15 + } + + // ========================== + // updateKupportersPoints & updateMemo + // ========================== + + @Test + fun `updateKupportersPoints - kupporters 포인트 변경 시 history와 레코드 갱신`() { + val member = createMember(1L) + val manual = ManualPoint( + member = member, + studyPoints = 0, + kupportersPoints = 5, + memo = null + ) + + every { manualPointRecordRepository.findByMemberId(1L) } returns manual + every { manualPointRecordRepository.save(any()) } answers { firstArg() } + + val result = service.updateKupportersPoints(1L, 8) + + assertEquals(1L, result.memberId) + assertEquals(8, result.kupportersPoints) + assertEquals(8, manual.kupportersPoints) + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + } + + @Test + fun `updateMemo - 메모만 변경하고 history는 기록하지 않는다`() { + val member = createMember(1L) + val manual = ManualPoint( + member = member, + studyPoints = 0, + kupportersPoints = 0, + memo = "old" + ) + + every { manualPointRecordRepository.findByMemberId(1L) } returns manual + every { manualPointRecordRepository.save(any()) } answers { firstArg() } + + val result = service.updateMemo(1L, "new memo") + + assertEquals(1L, result.memberId) + assertEquals("new memo", result.memo) + assertEquals("new memo", manual.memo) + verify(exactly = 0) { memberPointHistoryRepository.save(any()) } + } + + // ========================== + // updateIsTf & updateIsStaff + // ========================== + + @Test + fun `updateIsTf - false에서 true로 토글되면 TF 포인트 추가되고 true 반환`() { + val member = createMember(1L) + every { member.isTf } returns false + every { member.isTf = any() } just Runs + every { memberRepository.findById(1L) } returns Optional.of(member) + + val result = service.updateIsTf(1L) + + assertTrue(result) + verify { member.isTf = true } + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + } + + @Test + fun `updateIsTf - 회원 없으면 예외`() { + every { memberRepository.findById(1L) } returns Optional.empty() + + val ex = assertFailsWith { + service.updateIsTf(1L) + } + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + } + + @Test + fun `updateIsStaff - false에서 true로 토글되면 STAFF 포인트 추가`() { + val member = createMember(1L) + every { member.isStaff } returns false + every { member.isStaff = any() } just Runs + every { memberRepository.findById(1L) } returns Optional.of(member) + + val result = service.updateIsStaff(1L) + + assertTrue(result) + verify { member.isStaff = true } + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + } + + // ========================== + // updateKupickApproval + // ========================== + + @Test + fun `updateKupickApproval - 기존 Kupick이 있으면 approval 토글 및 history 기록`() { + val member = createMember(1L) + val targetYm = YearMonth.of(2025, 1) + + val kupick = mockk(relaxed = true) + every { kupick.id } returns 10L + every { kupick.approval } returns false + every { kupick.updateApproval(any()) } just Runs + + every { memberRepository.findById(1L) } returns Optional.of(member) + every { + kupickRepository.findThisMonthByMember(member, any(), any()) + } returns kupick + every { kupickRepository.save(kupick) } returns kupick + + val result = service.updateKupickApproval(1L, targetYm) + + assertEquals(1L, result.memberId) + assertEquals(10L, result.kupickId) + assertTrue(result.isKupick) + + verify { kupick.updateApproval(true) } + verify { memberPointHistoryRepository.save(any()) } + verify { kupickRepository.save(kupick) } + } + + @Test + fun `updateKupickApproval - 회원 없으면 예외`() { + every { memberRepository.findById(1L) } returns Optional.empty() + + val ex = assertFailsWith { + service.updateKupickApproval(1L, YearMonth.of(2025, 1)) + } + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + } + + @Test + fun `updateKupickApproval - save 후 id null이면 예외`() { + val member = createMember(1L) + val kupick = mockk(relaxed = true) + every { kupick.id } returns null + + every { memberRepository.findById(1L) } returns Optional.of(member) + every { kupickRepository.findThisMonthByMember(member, any(), any()) } returns kupick + every { kupickRepository.save(kupick) } returns kupick + + val ex = assertFailsWith { + service.updateKupickApproval(1L, YearMonth.of(2025, 1)) + } + assertEquals(KupickErrorCode.KUPICK_SAVE_FAILED, ex.errorCode) + } + + // ========================== + // updateAttendanceAndHistory + // ========================== + + @Test + fun `updateAttendanceAndHistory - attendance가 없으면 예외`() { + every { attendanceRepository.findById(1L) } returns Optional.empty() + + val ex = assertFailsWith { + service.updateAttendanceAndHistory( + attendanceId = 1L, + memberId = 1L, + newStatus = AttendancePointType.LATE + ) + } + assertEquals(AttendanceErrorCode.ATTENDANCE_NOT_FOUND, ex.errorCode) + } + + @Test + fun `updateAttendanceAndHistory - attendance의 memberId가 다르면 예외`() { + val attendance = mockk(relaxed = true) + every { attendance.memberId } returns 2L + every { attendanceRepository.findById(1L) } returns Optional.of(attendance) + + val ex = assertFailsWith { + service.updateAttendanceAndHistory( + attendanceId = 1L, + memberId = 1L, + newStatus = AttendancePointType.LATE + ) + } + assertEquals(AttendanceErrorCode.INVALID_MEMBER_FOR_ATTENDANCE, ex.errorCode) + } + + @Test + fun `updateAttendanceAndHistory - 상태가 같으면 저장 없이 diff 0으로 반환`() { + val attendance = mockk(relaxed = true) + every { attendance.memberId } returns 1L + every { attendance.sessionId } returns 10L + every { attendance.status } returns AttendancePointType.PRESENT + + val session = mockk(relaxed = true) + every { session.week } returns 3L + + every { attendanceRepository.findById(1L) } returns Optional.of(attendance) + every { sessionRepository.findById(10L) } returns Optional.of(session) + + val result = service.updateAttendanceAndHistory( + attendanceId = 1L, + memberId = 1L, + newStatus = AttendancePointType.PRESENT + ) + + assertEquals(0, result.diff) + assertEquals(AttendancePointType.PRESENT, result.oldStatus) + assertEquals(AttendancePointType.PRESENT, result.newStatus) + assertEquals(3L, result.week) + + verify(exactly = 0) { attendanceRepository.save(any()) } + verify(exactly = 0) { memberPointHistoryRepository.save(any()) } + } + + @Test + fun `updateAttendanceAndHistory - 상태가 변경되면 attendance와 history 저장`() { + val attendance = mockk(relaxed = true) + every { attendance.memberId } returns 1L + every { attendance.sessionId } returns 10L + every { attendance.status } returns AttendancePointType.ABSENT + every { attendance.status = any() } just Runs + every { attendanceRepository.save(any()) } answers { firstArg() } + + val session = mockk(relaxed = true) + every { session.week } returns 2L + + val member = createMember(1L) + + every { attendanceRepository.findById(1L) } returns Optional.of(attendance) + every { sessionRepository.findById(10L) } returns Optional.of(session) + every { memberRepository.findById(1L) } returns Optional.of(member) + + val result = service.updateAttendanceAndHistory( + attendanceId = 1L, + memberId = 1L, + newStatus = AttendancePointType.PRESENT + ) + + // PRESENT.points - ABSENT.points 의 diff + val expectedDiff = AttendancePointType.PRESENT.points - AttendancePointType.ABSENT.points + + assertEquals(AttendancePointType.ABSENT, result.oldStatus) + assertEquals(AttendancePointType.PRESENT, result.newStatus) + assertEquals(expectedDiff, result.diff) + assertEquals(2L, result.week) + + verify { attendance.status = AttendancePointType.PRESENT } + verify(exactly = 1) { attendanceRepository.save(attendance) } + verify(exactly = 1) { memberPointHistoryRepository.save(any()) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt b/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt new file mode 100644 index 0000000..5cf68a8 --- /dev/null +++ b/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt @@ -0,0 +1,333 @@ +package onku.backend.point.service + +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import onku.backend.domain.attendance.Attendance +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.attendance.repository.AttendanceRepository +import onku.backend.domain.kupick.repository.KupickRepository +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.enums.Part +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.point.ManualPoint +import onku.backend.domain.point.dto.AdminPointOverviewDto +import onku.backend.domain.point.dto.MemberMonthlyAttendanceDto +import onku.backend.domain.point.repository.ManualPointRepository +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.point.service.AdminPointService +import onku.backend.domain.session.Session +import onku.backend.domain.session.repository.SessionRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.page.PageResponse +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import java.time.* +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@ExtendWith(MockKExtension::class) +class AdminPointServiceTest { + + @MockK lateinit var memberProfileRepository: MemberProfileRepository + @MockK lateinit var kupickRepository: KupickRepository + @MockK lateinit var manualPointRecordRepository: ManualPointRepository + @MockK lateinit var sessionRepository: SessionRepository + @MockK lateinit var memberPointHistoryRepository: MemberPointHistoryRepository + @MockK lateinit var attendanceRepository: AttendanceRepository + + lateinit var clock: Clock + lateinit var service: AdminPointService + + @BeforeEach + fun setUp() { + clock = Clock.fixed( + Instant.parse("2025-01-01T01:00:00Z"), // Asia/Seoul → 10:00 + ZoneId.of("Asia/Seoul") + ) + + service = AdminPointService( + memberProfileRepository = memberProfileRepository, + kupickRepository = kupickRepository, + manualPointRecordRepository = manualPointRecordRepository, + sessionRepository = sessionRepository, + memberPointHistoryRepository = memberPointHistoryRepository, + attendanceRepository = attendanceRepository, + clock = clock + ) + } + + private fun createMember( + id: Long = 1L, + isTf: Boolean = false, + isStaff: Boolean = false + ): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + every { m.isTf } returns isTf + every { m.isStaff } returns isStaff + return m + } + + private fun createProfile( + memberId: Long, + member: Member, + name: String = "홍길동", + part: Part = Part.BACKEND, + phone: String? = "010-0000-0000", + school: String? = "K대학교", + major: String? = "컴퓨터공학" + ): MemberProfile { + val p = mockk(relaxed = true) + every { p.memberId } returns memberId + every { p.member } returns member + every { p.name } returns name + every { p.part } returns part + every { p.phoneNumber } returns phone + every { p.school } returns school + every { p.major } returns major + return p + } + + // ========================== + // getAdminOverview + // ========================== + + @Test + fun `getAdminOverview - 한 명에 대해 월별 출석, 쿠픽 참여, 수동 포인트를 잘 매핑한다`() { + val year = 2025 + val page = 0 + val size = 10 + val pageable = PageRequest.of(page, size) + + val member = createMember(id = 1L, isTf = true, isStaff = false) + val profile = createProfile(memberId = 1L, member = member) + + every { + memberProfileRepository.findAllByOrderByPartAscNameAsc(any()) + } returns PageImpl(listOf(profile), pageable, 1) + + // 1) 출석 포인트 합산 결과 (8~12월 중 9월만 30점 있다고 가정) + every { + memberPointHistoryRepository.sumAttendanceByMemberAndMonth( + memberIds = listOf(1L), + category = any(), + start = any(), + end = any() + ) + } returns listOf( + mockk { + every { getMemberId() } returns 1L + every { getMonth() } returns 9 + every { getPoints() } returns 30L + } + ) + + // 2) 큐픽 참여 여부 (8~12 중 10월에만 참여) + every { + kupickRepository.findMemberMonthParticipation( + listOf(1L), + any(), + any() + ) + } returns listOf( + arrayOf(1L, 10) // memberId, month + ) + + // 3) 수동 포인트 (study = 5, kuporters = 7) + val manual = mockk() + + every { manual.memberId } returns 1L + every { manual.studyPoints } returns 5 + every { manual.kupportersPoints } returns 7 + every { manual.memo } returns "열심히 함" + every { manual.member } returns member // isTf, isStaff 볼 수도 있으니 안전하게 + + every { + manualPointRecordRepository.findByMemberIdIn(listOf(1L)) + } returns listOf(manual) + + val response: PageResponse = + service.getAdminOverview(year = year, page = page, size = size) + + assertEquals(1, response.data.size) + + val dto = response.data[0] + assertEquals(1L, dto.memberId) + assertEquals("홍길동", dto.name) + assertEquals(Part.BACKEND, dto.part) + assertEquals("010-0000-0000", dto.phoneNumber) + assertEquals("K대학교", dto.school) + assertEquals("컴퓨터공학", dto.major) + assertTrue(dto.isTf) + assertFalse(dto.isStaff) + + // 8~12월 키가 모두 있어야 함 + assertEquals(setOf(8, 9, 10, 11, 12), dto.attendanceMonthlyTotals.keys) + // 9월만 30점 + assertEquals(30, dto.attendanceMonthlyTotals[9]) + assertEquals(0, dto.attendanceMonthlyTotals[8]) + assertEquals(0, dto.attendanceMonthlyTotals[10]) + + // 큐픽 참여: 10월만 true + assertEquals(setOf(8, 9, 10, 11, 12), dto.kupickParticipation.keys) + assertEquals(true, dto.kupickParticipation[10]) + assertEquals(false, dto.kupickParticipation[8]) + + // 수동 포인트 + assertEquals(5, dto.studyPoints) + assertEquals(7, dto.kuportersPoints) + assertEquals("열심히 함", dto.memo) + } + + @Test + fun `getAdminOverview - 멤버가 아예 없으면 빈 페이지 반환`() { + val pageable = PageRequest.of(0, 10) + every { + memberProfileRepository.findAllByOrderByPartAscNameAsc(any()) + } returns PageImpl(emptyList(), pageable, 0) + + val response = service.getAdminOverview( + year = 2025, + page = 0, + size = 10 + ) + + assertTrue(response.data.isEmpty()) + } + + // ========================== + // getMonthlyPaged + // ========================== + + @Test + fun `getMonthlyPaged - 해당 월 세션이 하나도 없으면 sessionDates와 members 비어있다`() { + every { + sessionRepository.findByStartDateBetween(any(), any()) + } returns emptyList() + + val result = service.getMonthlyPaged( + year = 2025, + month = 1, + page = 0, + size = 10 + ) + + assertEquals(2025, result.year) + assertEquals(1, result.month) + assertTrue(result.sessionDates.isEmpty()) + assertTrue(result.members.data.isEmpty()) + } + + @Test + fun `getMonthlyPaged - 세션 2개, 출석 1개만 있을 때 나머지 날짜는 status null로 채운다`() { + val year = 2025 + val month = 1 + + // 세션 2개: 1월 5일, 1월 20일 + val session1 = mockk(relaxed = true) + every { session1.id } returns 10L + every { session1.startDate } returns LocalDate.of(year, month, 5) + + val session2 = mockk(relaxed = true) + every { session2.id } returns 11L + every { session2.startDate } returns LocalDate.of(year, month, 20) + + every { + sessionRepository.findByStartDateBetween(any(), any()) + } returns listOf(session1, session2) + + // 페이지 멤버 1명 + val pageable = PageRequest.of(0, 10) + val member = createMember(1L) + val profile = createProfile(memberId = 1L, member = member, name = "테스터") + + every { + memberProfileRepository.findAllByOrderByPartAscNameAsc(pageable) + } returns PageImpl(listOf(profile), pageable, 1) + + // 출석: 이 멤버는 1월 5일(세션1)에만 PRESENT + val attendance = mockk(relaxed = true) + every { attendance.id } returns 100L + every { attendance.memberId } returns 1L + every { attendance.sessionId } returns 10L + every { attendance.status } returns AttendancePointType.PRESENT + every { attendance.attendanceTime } returns LocalDateTime.of(year, month, 5, 10, 0) + + every { + attendanceRepository.findByMemberIdInAndAttendanceTimeBetween( + listOf(1L), + any(), + any() + ) + } returns listOf(attendance) + + val result = service.getMonthlyPaged( + year = year, + month = month, + page = 0, + size = 10 + ) + + assertEquals(year, result.year) + assertEquals(month, result.month) + // sessionDates = [5, 20] + assertEquals(listOf(5, 20), result.sessionDates) + + // 멤버는 한 명 + assertEquals(1, result.members.data.size) + val memberDto: MemberMonthlyAttendanceDto = result.members.data[0] + assertEquals(1L, memberDto.memberId) + assertEquals("테스터", memberDto.name) + + // records 는 날짜 2개에 대해 존재해야 한다 + assertEquals(2, memberDto.records.size) + val rec1 = memberDto.records[0] + val rec2 = memberDto.records[1] + + assertEquals(LocalDate.of(year, month, 5), rec1.date) + assertEquals(AttendancePointType.PRESENT, rec1.status) + assertEquals(AttendancePointType.PRESENT.points, rec1.point) + + assertEquals(LocalDate.of(year, month, 20), rec2.date) + assertNull(rec2.status) + assertNull(rec2.point) + } + + @Test + fun `getMonthlyPaged - 페이지에 멤버가 없으면 예외 발생`() { + val year = 2025 + val month = 1 + + // 세션은 있는데 + val session = mockk(relaxed = true) + every { session.id } returns 10L + every { session.startDate } returns LocalDate.of(year, month, 5) + every { + sessionRepository.findByStartDateBetween(any(), any()) + } returns listOf(session) + + // member 페이지는 비어있음 + val pageable = PageRequest.of(0, 10) + every { + memberProfileRepository.findAllByOrderByPartAscNameAsc(pageable) + } returns PageImpl(emptyList(), pageable, 0) + + val ex = assertFailsWith { + service.getMonthlyPaged( + year = year, + month = month, + page = 0, + size = 10 + ) + } + + assertEquals(MemberErrorCode.PAGE_MEMBERS_NOT_FOUND, ex.errorCode) + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/point/service/MemberPointHistoryServiceTest.kt b/src/test/kotlin/onku/backend/point/service/MemberPointHistoryServiceTest.kt new file mode 100644 index 0000000..f832020 --- /dev/null +++ b/src/test/kotlin/onku/backend/point/service/MemberPointHistoryServiceTest.kt @@ -0,0 +1,265 @@ +package onku.backend.point.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import onku.backend.domain.absence.AbsenceReport +import onku.backend.domain.absence.enums.AbsenceApprovedType +import onku.backend.domain.absence.enums.AbsenceReportApproval +import onku.backend.domain.attendance.enums.AttendancePointType +import onku.backend.domain.attendance.util.AbsenceReportToAttendancePointMapper +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.point.MemberPointHistory +import onku.backend.domain.point.converter.MemberPointConverter +import onku.backend.domain.point.dto.MemberPointHistoryResponse +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.domain.point.service.MemberPointHistoryService +import onku.backend.domain.session.Session +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@ExtendWith(MockKExtension::class) +class MemberPointHistoryServiceTest { + + @MockK lateinit var recordRepository: MemberPointHistoryRepository + @MockK lateinit var memberProfileRepository: MemberProfileRepository + + lateinit var service: MemberPointHistoryService + + @BeforeEach + fun setUp() { + service = MemberPointHistoryService( + recordRepository = recordRepository, + memberProfileRepository = memberProfileRepository + ) + } + + private fun createMember(id: Long = 1L): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + return m + } + + private fun createProfile(member: Member, name: String? = "홍길동"): MemberProfile { + val p = mockk(relaxed = true) + every { p.memberId } returns member.id + every { p.member } returns member + every { p.name } returns name + return p + } + + // ========================== + // getHistory + // ========================== + + @Test + fun `getHistory - 누적 포인트와 기록 페이지를 잘 매핑한다`() { + val member = createMember(1L) + val pageIndex = 0 + val size = 10 + val pageable = PageRequest.of(pageIndex, size) + + val profile = createProfile(member, "김테스터") + every { memberProfileRepository.findById(1L) } returns java.util.Optional.of(profile) + + // 합계 projection mock + val sums = mockk { + every { getPlusPoints() } returns 100L + every { getMinusPoints() } returns 20L + every { getTotalPoints() } returns 80L + } + every { recordRepository.sumPointsForMember(member) } returns sums + + // 히스토리 엔티티 + 페이지 + val history1 = mockk(relaxed = true) + val history2 = mockk(relaxed = true) + + every { + recordRepository.findByMemberOrderByOccurredAtDesc(member, pageable) + } returns PageImpl(listOf(history1, history2), pageable, 2) + + // Converter mock + mockkObject(MemberPointConverter) + val dto1 = mockk() + val dto2 = mockk() + + every { MemberPointConverter.toResponse(history1) } returns dto1 + every { MemberPointConverter.toResponse(history2) } returns dto2 + + // when + val response: MemberPointHistoryResponse = + service.getHistory(member, pageIndex, size) + + // then + assertEquals(1L, response.memberId) + assertEquals("김테스터", response.name) + assertEquals(100, response.plusPoints) + assertEquals(20, response.minusPoints) + assertEquals(80, response.totalPoints) + + assertEquals(2, response.records.size) + assertSame(dto1, response.records[0]) + assertSame(dto2, response.records[1]) + + assertEquals(1, response.totalPages) // totalElements=2, size=10 → totalPages=1 + assertTrue(response.isLastPage) + + verify { memberProfileRepository.findById(1L) } + verify { recordRepository.sumPointsForMember(member) } + verify { recordRepository.findByMemberOrderByOccurredAtDesc(member, pageable) } + } + + @Test + fun `getHistory - 프로필이 없으면 MEMBER_NOT_FOUND 예외`() { + val member = createMember(1L) + every { memberProfileRepository.findById(1L) } returns java.util.Optional.empty() + + val ex = assertFailsWith { + service.getHistory(member, safePage = 0, size = 10) + } + + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + verify { memberProfileRepository.findById(1L) } + verify(exactly = 0) { recordRepository.sumPointsForMember(any()) } + } + + // ========================== + // upsertPointFromAbsenceReport + // ========================== + + @Test + fun `upsertPointFromAbsenceReport - 기존 기록 없으면 새로 생성해서 저장`() { + val member = createMember(1L) + val session = mockk(relaxed = true) + every { session.week } returns 3L + + val absence = mockk(relaxed = true) + every { absence.member } returns member + every { absence.session } returns session + every { absence.approval } returns AbsenceReportApproval.APPROVED + every { absence.approvedType } returns AbsenceApprovedType.EXCUSED + + every { + recordRepository.findByWeekAndMember(3L, member) + } returns null + + // mapper + factory mock + mockkObject(AbsenceReportToAttendancePointMapper) + every { + AbsenceReportToAttendancePointMapper.map( + AbsenceReportApproval.APPROVED, + AbsenceApprovedType.EXCUSED + ) + } returns AttendancePointType.EXCUSED + + mockkObject(MemberPointHistory) + val newHistory = mockk(relaxed = true) + + every { + MemberPointHistory.ofAttendance( + member = member, + status = AttendancePointType.EXCUSED, + occurredAt = any(), + week = 3L + ) + } returns newHistory + + every { recordRepository.save(newHistory) } returns newHistory + + // when + service.upsertPointFromAbsenceReport(absence) + + // then + verify { + recordRepository.findByWeekAndMember(3L, member) + } + verify { + AbsenceReportToAttendancePointMapper.map( + AbsenceReportApproval.APPROVED, + AbsenceApprovedType.EXCUSED + ) + } + verify { + MemberPointHistory.ofAttendance( + member = member, + status = AttendancePointType.EXCUSED, + occurredAt = any(), + week = 3L + ) + } + verify { recordRepository.save(newHistory) } + } + + @Test + fun `upsertPointFromAbsenceReport - 기존 기록 있으면 updateAttendancePointType 호출 후 save`() { + val member = createMember(1L) + val session = mockk(relaxed = true) + every { session.week } returns 4L + + val absence = mockk(relaxed = true) + every { absence.member } returns member + every { absence.session } returns session + every { absence.approval } returns AbsenceReportApproval.APPROVED + every { absence.approvedType } returns AbsenceApprovedType.ABSENT + + val existingHistory = mockk(relaxed = true) + + every { + recordRepository.findByWeekAndMember(4L, member) + } returns existingHistory + + mockkObject(AbsenceReportToAttendancePointMapper) + every { + AbsenceReportToAttendancePointMapper.map( + AbsenceReportApproval.APPROVED, + AbsenceApprovedType.ABSENT + ) + } returns AttendancePointType.ABSENT + + every { + existingHistory.updateAttendancePointType( + status = AttendancePointType.ABSENT, + occurredAt = any() + ) + } just Runs + + every { recordRepository.save(existingHistory) } returns existingHistory + + // when + service.upsertPointFromAbsenceReport(absence) + + // then + verify { + recordRepository.findByWeekAndMember(4L, member) + } + verify { + AbsenceReportToAttendancePointMapper.map( + AbsenceReportApproval.APPROVED, + AbsenceApprovedType.ABSENT + ) + } + verify { + existingHistory.updateAttendancePointType( + status = AttendancePointType.ABSENT, + occurredAt = any() + ) + } + verify { recordRepository.save(existingHistory) } + + // 새로 만드는 ofAttendance는 호출되면 안 됨 + mockkObject(MemberPointHistory) + verify(exactly = 0) { + MemberPointHistory.ofAttendance(any(), any(), any(), any()) + } + } +} \ No newline at end of file From c89165b5a09d5c86157da995a4a641986ab1b1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 24 Nov 2025 19:09:49 +0900 Subject: [PATCH 441/470] =?UTF-8?q?test=20:=20=ED=81=90=ED=94=BD=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kupick/service/KupickServiceTest.kt | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt diff --git a/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt b/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt new file mode 100644 index 0000000..621eca4 --- /dev/null +++ b/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt @@ -0,0 +1,297 @@ +package onku.backend.kupick.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import onku.backend.domain.kupick.Kupick +import onku.backend.domain.kupick.KupickErrorCode +import onku.backend.domain.kupick.dto.KupickMemberInfo +import onku.backend.domain.kupick.repository.KupickRepository +import onku.backend.domain.kupick.repository.projection.KupickUrls +import onku.backend.domain.kupick.repository.projection.KupickWithProfile +import onku.backend.domain.kupick.service.KupickService +import onku.backend.domain.member.Member +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito.verify +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@ExtendWith(MockKExtension::class) +class KupickServiceTest { + + @MockK + lateinit var kupickRepository: KupickRepository + + lateinit var kupickService: KupickService + + @BeforeEach + fun setUp() { + kupickService = KupickService(kupickRepository) + } + + private fun createMember(id: Long = 1L): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + return m + } + + // ========================== + // submitApplication + // ========================== + + @Test + fun `submitApplication - 기존 신청이 없으면 새로 생성하고 null 반환`() { + val member = createMember(1L) + val newUrl = "s3://kupick/apply/new.png" + + every { + kupickRepository.findFirstByMemberAndApplicationDateBetween( + member, + any(), // startOfMonth + any() // startOfNextMonth + ) + } returns null + + every { + kupickRepository.save(any()) + } returns mockk(relaxed = true) + + val result = kupickService.submitApplication(member, newUrl) + + assertNull(result) // 기존 URL 없음 → null + + verify(exactly = 1) { + kupickRepository.findFirstByMemberAndApplicationDateBetween(member, any(), any()) + } + verify(exactly = 1) { + kupickRepository.save(any()) + } + } + + @Test + fun `submitApplication - 기존 신청 있고 미승인이면 기존 URL 반환하고 updateApplication 호출`() { + val member = createMember(1L) + val oldUrl = "s3://kupick/apply/old.png" + val newUrl = "s3://kupick/apply/new.png" + + val existing = mockk(relaxed = true) + every { existing.approval } returns false + every { existing.applicationImageUrl } returns oldUrl + every { existing.updateApplication(newUrl, any()) } just Runs + + every { + kupickRepository.findFirstByMemberAndApplicationDateBetween(member, any(), any()) + } returns existing + + val result = kupickService.submitApplication(member, newUrl) + + assertEquals(oldUrl, result) + + verify(exactly = 1) { + kupickRepository.findFirstByMemberAndApplicationDateBetween(member, any(), any()) + } + verify(exactly = 1) { + existing.updateApplication(newUrl, any()) + } + verify(exactly = 0) { kupickRepository.save(any()) } + } + + @Test + fun `submitApplication - 기존 신청이 승인 상태면 KUPICK_NOT_UPDATE 예외`() { + val member = createMember(1L) + val newUrl = "s3://kupick/apply/new.png" + + val existing = mockk(relaxed = true) + every { existing.approval } returns true + + every { + kupickRepository.findFirstByMemberAndApplicationDateBetween(member, any(), any()) + } returns existing + + val ex = assertFailsWith { + kupickService.submitApplication(member, newUrl) + } + + assertEquals(KupickErrorCode.KUPICK_NOT_UPDATE, ex.errorCode) + + verify(exactly = 1) { + kupickRepository.findFirstByMemberAndApplicationDateBetween(member, any(), any()) + } + verify(exactly = 0) { kupickRepository.save(any()) } + } + + // ========================== + // submitView + // ========================== + + @Test + fun `submitView - 이번달 신청이 없으면 KUPICK_APPLICATION_FIRST 예외`() { + val member = createMember(1L) + val viewUrl = "s3://kupick/view/new.png" + + every { + kupickRepository.findThisMonthByMember(member, any(), any()) + } returns null + + val ex = assertFailsWith { + kupickService.submitView(member, viewUrl) + } + + assertEquals(KupickErrorCode.KUPICK_APPLICATION_FIRST, ex.errorCode) + verify { kupickRepository.findThisMonthByMember(member, any(), any()) } + } + + @Test + fun `submitView - 이미 승인된 큐픽이면 KUPICK_NOT_UPDATE 예외`() { + val member = createMember(1L) + val viewUrl = "s3://kupick/view/new.png" + + val existing = mockk(relaxed = true) + every { existing.approval } returns true + + every { + kupickRepository.findThisMonthByMember(member, any(), any()) + } returns existing + + val ex = assertFailsWith { + kupickService.submitView(member, viewUrl) + } + + assertEquals(KupickErrorCode.KUPICK_NOT_UPDATE, ex.errorCode) + + verify { kupickRepository.findThisMonthByMember(member, any(), any()) } + verify(exactly = 0) { existing.submitView(any(), any()) } + } + + @Test + fun `submitView - 정상 제출이면 이전 viewUrl 반환하고 submitView 호출`() { + val member = createMember(1L) + val oldViewUrl = "s3://kupick/view/old.png" + val newViewUrl = "s3://kupick/view/new.png" + + val existing = mockk(relaxed = true) + every { existing.approval } returns false + every { existing.viewImageUrl } returns oldViewUrl + every { existing.submitView(newViewUrl, any()) } just Runs + + every { + kupickRepository.findThisMonthByMember(member, any(), any()) + } returns existing + + val result = kupickService.submitView(member, newViewUrl) + + assertEquals(oldViewUrl, result) + + verify { kupickRepository.findThisMonthByMember(member, any(), any()) } + verify { existing.submitView(newViewUrl, any()) } + } + + // ========================== + // viewMyKupick + // ========================== + + @Test + fun `viewMyKupick - 이번달 조회 결과가 있으면 그대로 반환`() { + val member = createMember(1L) + val urls = mockk() + + every { + kupickRepository.findUrlsForMemberInMonth(member, any(), any()) + } returns urls + + val result = kupickService.viewMyKupick(member) + + assertSame(urls, result) + verify { + kupickRepository.findUrlsForMemberInMonth(member, any(), any()) + } + } + + @Test + fun `viewMyKupick - 이번달 조회 결과가 없으면 null`() { + val member = createMember(1L) + + every { + kupickRepository.findUrlsForMemberInMonth(member, any(), any()) + } returns null + + val result = kupickService.viewMyKupick(member) + + assertNull(result) + verify { + kupickRepository.findUrlsForMemberInMonth(member, any(), any()) + } + } + + // ========================== + // findAllAsShowUpdateResponse + // ========================== + + @Test + fun `findAllAsShowUpdateResponse - year, month에 대한 결과 리스트 그대로 반환`() { + val year = 2025 + val month = 9 + + val row1 = mockk() + val row2 = mockk() + + every { + kupickRepository.findAllWithProfile(any(), any()) + } returns listOf(row1, row2) + + val result = kupickService.findAllAsShowUpdateResponse(year, month) + + assertEquals(2, result.size) + assertSame(row1, result[0]) + assertSame(row2, result[1]) + + verify { kupickRepository.findAllWithProfile(any(), any()) } + } + + // ========================== + // decideApproval + // ========================== + + @Test + fun `decideApproval - 존재하지 않으면 KUPICK_NOT_FOUND 예외`() { + every { kupickRepository.findById(1L) } returns java.util.Optional.empty() + + val ex = assertFailsWith { + kupickService.decideApproval(1L, true) + } + + assertEquals(KupickErrorCode.KUPICK_NOT_FOUND, ex.errorCode) + } + + @Test + fun `decideApproval - 존재하면 updateApproval 호출`() { + val kupick = mockk(relaxed = true) + every { kupickRepository.findById(1L) } returns java.util.Optional.of(kupick) + every { kupick.updateApproval(true) } just Runs + + kupickService.decideApproval(1L, true) + + verify { kupickRepository.findById(1L) } + verify { kupick.updateApproval(true) } + } + + // ========================== + // findFcmInfo + // ========================== + + @Test + fun `findFcmInfo - 레포에서 조회한 KupickMemberInfo를 그대로 반환`() { + val info = mockk() + + every { kupickRepository.findFcmInfoByKupickId(10L) } returns info + + val result = kupickService.findFcmInfo(10L) + + assertSame(info, result) + verify { kupickRepository.findFcmInfoByKupickId(10L) } + } +} \ No newline at end of file From 8da516a24160cede0297726878dfb58d24ec1350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 24 Nov 2025 20:14:11 +0900 Subject: [PATCH 442/470] =?UTF-8?q?test=20:=20=EB=A9=A4=EB=B2=84=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B4=80=EB=A0=A8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MemberAlarmHistoryServiceTest.kt | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 src/test/kotlin/onku/backend/member/service/MemberAlarmHistoryServiceTest.kt diff --git a/src/test/kotlin/onku/backend/member/service/MemberAlarmHistoryServiceTest.kt b/src/test/kotlin/onku/backend/member/service/MemberAlarmHistoryServiceTest.kt new file mode 100644 index 0000000..79ba4ac --- /dev/null +++ b/src/test/kotlin/onku/backend/member/service/MemberAlarmHistoryServiceTest.kt @@ -0,0 +1,108 @@ +package onku.backend.member.service + +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberAlarmHistory +import onku.backend.domain.member.dto.MemberAlarmHistoryItemResponse +import onku.backend.domain.member.repository.MemberAlarmHistoryRepository +import onku.backend.domain.member.service.MemberAlarmHistoryService +import onku.backend.global.alarm.enums.AlarmEmojiType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import java.time.LocalDateTime +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class MemberAlarmHistoryServiceTest { + + @MockK lateinit var memberAlarmHistoryRepository: MemberAlarmHistoryRepository + + lateinit var service: MemberAlarmHistoryService + + @BeforeEach + fun setUp() { + service = MemberAlarmHistoryService(memberAlarmHistoryRepository) + } + + private fun createMember(id: Long = 1L): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + return m + } + + // getMyAlarms 테스트 + @Test + fun `getMyAlarms - 히스토리를 최신순으로 DTO로 변환해서 반환한다`() { + // given + val member = createMember(1L) + + val createdAt1 = LocalDateTime.of(2025, 1, 1, 10, 0) + val createdAt2 = LocalDateTime.of(2025, 1, 2, 9, 30) + + // 엔티티는 mock으로 충분 (필드 몇 개만 쓰니까) + val history1 = mockk() + every { history1.message } returns "첫 번째 알림" + every { history1.type } returns AlarmEmojiType.STAR + every { history1.createdAt } returns createdAt1 + + val history2 = mockk() + every { history2.message } returns "두 번째 알림" + every { history2.type } returns AlarmEmojiType.WARNING + every { history2.createdAt } returns createdAt2 + + every { + memberAlarmHistoryRepository.findByMemberOrderByCreatedAtDesc(member) + } returns listOf(history2, history1) + + // when + val result: List = service.getMyAlarms(member) + + // then + assertEquals(2, result.size) + + assertEquals("두 번째 알림", result[0].message) + assertEquals(AlarmEmojiType.WARNING, result[0].type) + assertEquals("01/02 09:30", result[0].createdAt) + + assertEquals("첫 번째 알림", result[1].message) + assertEquals(AlarmEmojiType.STAR, result[1].type) + assertEquals("01/01 10:00", result[1].createdAt) + + verify(exactly = 1) { + memberAlarmHistoryRepository.findByMemberOrderByCreatedAtDesc(member) + } + } + + // saveAlarm 테스트 + @Test + fun `saveAlarm - member와 타입, 메시지로 알림 히스토리를 저장한다`() { + // given + val member = createMember(1L) + val type = AlarmEmojiType.STAR + val message = "쿠픽 승인 되었습니다." + + val slot = slot() + every { memberAlarmHistoryRepository.save(capture(slot)) } answers { + slot.captured + } + + // when + service.saveAlarm(member, type, message) + + // then + verify(exactly = 1) { + memberAlarmHistoryRepository.save(any()) + } + + val saved = slot.captured + assertEquals(member, saved.member) + assertEquals(message, saved.message) + assertEquals(type, saved.type) + } +} \ No newline at end of file From d46672d0b6579b299dd2b107c147bdabd2ec786c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 24 Nov 2025 20:14:28 +0900 Subject: [PATCH 443/470] =?UTF-8?q?chore=20:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EA=B5=AC=EB=AC=B8=20=EC=82=AD=EC=A0=9C=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../absence/service/AbsenceServiceTest.kt | 27 +- .../service/AttendanceFinalizeServiceTest.kt | 2 - .../service/AttendanceServiceTest.kt | 22 +- .../kupick/service/KupickServiceTest.kt | 2 +- .../service/MemberProfileServiceTest.kt | 444 ++++++++++++++++++ .../member/service/MemberServiceTest.kt | 285 +++++++++++ .../service/AdminPointCommandServiceTest.kt | 13 +- .../point/service/AdminPointServiceTest.kt | 4 - .../service/MemberPointHistoryServiceTest.kt | 5 - .../service/SessionDetailServiceTest.kt | 22 +- .../service/SessionImageServiceTest.kt | 8 - .../service/SessionNoticeServiceTest.kt | 12 +- .../session/service/SessionServiceTest.kt | 33 +- 13 files changed, 766 insertions(+), 113 deletions(-) create mode 100644 src/test/kotlin/onku/backend/member/service/MemberProfileServiceTest.kt create mode 100644 src/test/kotlin/onku/backend/member/service/MemberServiceTest.kt diff --git a/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt b/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt index 9441c3b..367711a 100644 --- a/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt +++ b/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt @@ -42,9 +42,7 @@ class AbsenceServiceTest { private val session = mockk(relaxed = true) private val fileKey = "test-file-key" - // ========================== - // 1) 새로운 결석신고 생성 테스트 - // ========================== + // 새로운 결석신고 생성 테스트 @Test fun `submitAbsenceReport - id가 없으면 새로 생성해서 save 한다`() { // given @@ -58,7 +56,7 @@ class AbsenceServiceTest { null ) - mockkObject(AbsenceReport) // companion object mock + mockkObject(AbsenceReport) val createdReport = mockk() every { AbsenceReport.createAbsenceReport( @@ -81,9 +79,9 @@ class AbsenceServiceTest { verify(exactly = 1) { absenceReportRepository.save(createdReport) } } - // ========================== - // 2) 기존 결석신고 수정 테스트 - // ========================== + + // 기존 결석신고 수정 테스트 + @Test fun `submitAbsenceReport - id가 있으면 기존 엔티티 update 후 save 한다`() { // given @@ -112,9 +110,8 @@ class AbsenceServiceTest { verify(exactly = 1) { absenceReportRepository.save(existingReport) } } - // =================================== - // 3) getMyAbsenceReports 매핑 테스트 - // =================================== + + // getMyAbsenceReports 매핑 테스트 @Test fun `getMyAbsenceReports - projection 리스트를 응답 DTO로 잘 매핑한다`() { val projection = mockk() @@ -140,9 +137,8 @@ class AbsenceServiceTest { assertEquals("테스트 세션", dto.sessionTitle) } - // ========================== - // 4) getById 성공 케이스 - // ========================== + + // getById 성공 케이스 @Test fun `getById - 존재하면 엔티티를 리턴한다`() { val report = mockk() @@ -153,9 +149,8 @@ class AbsenceServiceTest { assertSame(report, result) } - // ========================== - // 5) getById 실패 케이스 - // ========================== + + // getById 실패 케이스 @Test fun `getById - 없으면 CustomException을 던진다`() { every { absenceReportRepository.findById(999L) } returns java.util.Optional.empty() diff --git a/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt b/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt index 6a4746d..d56c565 100644 --- a/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt +++ b/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt @@ -41,7 +41,6 @@ class AttendanceFinalizeServiceTest { @MockK lateinit var em: EntityManager - // Clock은 실제 fixed 인스턴스로 사용 lateinit var clock: Clock lateinit var attendanceFinalizeService: AttendanceFinalizeService @@ -67,7 +66,6 @@ class AttendanceFinalizeServiceTest { every { SessionTimeUtil.startDateTime(any()) } returns LocalDateTime.of(2025, 1, 1, 10, 0) } - // 공통으로 쓸 session mock private fun createSession( id: Long = 1L, week: Long = 1L, diff --git a/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt b/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt index f947968..4468f22 100644 --- a/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt +++ b/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt @@ -51,7 +51,6 @@ class AttendanceServiceTest { @BeforeEach fun setUp() { - // now = 2025-01-01T10:00:00 in Asia/Seoul clock = Clock.fixed( Instant.parse("2025-01-01T01:00:00Z"), ZoneId.of("Asia/Seoul") @@ -95,9 +94,7 @@ class AttendanceServiceTest { every { memberProfileRepository.findById(memberId) } returns Optional.of(profile) } - // ========================== - // 1) issueAttendanceTokenFor - // ========================== + // issueAttendanceTokenFor @Test fun `issueAttendanceTokenFor - 토큰 발급 및 캐시에 저장`() { val member = createMember(1L) @@ -126,9 +123,7 @@ class AttendanceServiceTest { } } - // ========================== - // 2) scanAndRecordBy - 정상 출석 기록 - // ========================== + // scanAndRecordBy - 정상 출석 기록 @Test fun `scanAndRecordBy - 정상 출석 기록 및 포인트 적립`() { val admin = createMember(id = 99L) @@ -138,12 +133,10 @@ class AttendanceServiceTest { val now = LocalDateTime.now(clock) val session = createSession(id = 10L, week = 2L, isHoliday = false) - // 세션 열려있음 (findOpenSession 내부: findOpenWindow(startBound, now)) every { sessionRepository.findOpenWindow(any(), now) } returns listOf(session) - // 👉 TokenData 실제 인스턴스 생성 val peekResult = TokenData( memberId = member.id!!, issuedAt = now.minusSeconds(10), @@ -205,13 +198,10 @@ class AttendanceServiceTest { ) } - // save는 any()로 한 번 호출됐는지만 검증 verify(exactly = 1) { memberPointHistoryRepository.save(any()) } } - // ========================== - // 3) scanAndRecordBy - 세션 안 열렸을 때 예외 - // ========================== + // scanAndRecordBy - 세션 안 열렸을 때 예외 @Test fun `scanAndRecordBy - 열려있는 세션이 없으면 예외`() { val admin = createMember(99L) @@ -226,9 +216,9 @@ class AttendanceServiceTest { assertEquals(AttendanceErrorCode.SESSION_NOT_OPEN, ex.errorCode) } - // ========================== - // 4) checkAvailabilityFor - // ========================== + + // checkAvailabilityFor + @Test fun `checkAvailabilityFor - 세션 없으면 available false`() { val member = createMember(1L) diff --git a/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt b/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt index 621eca4..0f8e553 100644 --- a/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt +++ b/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt @@ -62,7 +62,7 @@ class KupickServiceTest { val result = kupickService.submitApplication(member, newUrl) - assertNull(result) // 기존 URL 없음 → null + assertNull(result) verify(exactly = 1) { kupickRepository.findFirstByMemberAndApplicationDateBetween(member, any(), any()) diff --git a/src/test/kotlin/onku/backend/member/service/MemberProfileServiceTest.kt b/src/test/kotlin/onku/backend/member/service/MemberProfileServiceTest.kt new file mode 100644 index 0000000..db2c264 --- /dev/null +++ b/src/test/kotlin/onku/backend/member/service/MemberProfileServiceTest.kt @@ -0,0 +1,444 @@ +package onku.backend.member.service + +import io.mockk.Runs +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.just +import io.mockk.mockk +import io.mockk.* +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.dto.MemberProfileBasicsResponse +import onku.backend.domain.member.dto.MemberProfileUpdateRequest +import onku.backend.domain.member.dto.MembersPagedResponse +import onku.backend.domain.member.dto.OnboardingRequest +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Part +import onku.backend.domain.member.enums.Role +import onku.backend.domain.member.enums.SocialType +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.member.service.MemberProfileService +import onku.backend.domain.member.service.MemberService +import onku.backend.domain.point.repository.MemberPointHistoryRepository +import onku.backend.global.exception.CustomException +import onku.backend.global.s3.dto.GetS3UrlDto +import onku.backend.global.s3.dto.GetUpdateAndDeleteUrlDto +import onku.backend.global.s3.enums.FolderName +import onku.backend.global.s3.enums.UploadOption +import onku.backend.global.s3.service.S3Service +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import java.util.* +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@ExtendWith(MockKExtension::class) +class MemberProfileServiceTest { + + @MockK lateinit var memberProfileRepository: MemberProfileRepository + @MockK lateinit var memberRepository: MemberRepository + @MockK lateinit var memberService: MemberService + @MockK lateinit var memberPointHistoryRepository: MemberPointHistoryRepository + @MockK lateinit var s3Service: S3Service + + lateinit var service: MemberProfileService + + @BeforeEach + fun setUp() { + service = MemberProfileService( + memberProfileRepository = memberProfileRepository, + memberRepository = memberRepository, + memberService = memberService, + memberPointHistoryRepository = memberPointHistoryRepository, + s3Service = s3Service + ) + } + + private fun createMember( + id: Long = 1L, + hasInfo: Boolean = false, + approval: ApprovalStatus = ApprovalStatus.PENDING, + isStaff: Boolean = false, + role: Role = Role.GUEST, + socialType: SocialType = SocialType.KAKAO + ): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + every { m.hasInfo } returns hasInfo + every { m.approval } returns approval + every { m.isStaff } returns isStaff + every { m.role } returns role + every { m.socialType } returns socialType + every { m.email } returns "user$id@test.com" + every { m.updateFcmToken(any()) } just Runs + return m + } + + private fun createProfile( + member: Member, + name: String? = "홍길동", + part: Part = Part.BACKEND, + school: String? = "K대", + major: String? = "컴공", + phone: String? = "010-0000-0000", + profileImage: String? = null + ): MemberProfile { + return MemberProfile( + member = member, + name = name, + school = school, + major = major, + part = part, + phoneNumber = phone, + profileImage = profileImage + ) + } + + // submitOnboarding + + @Test + fun `submitOnboarding - PENDING + hasInfo false면 프로필 생성하고 온보딩 처리`() { + val member = createMember(id = 1L, hasInfo = false, approval = ApprovalStatus.PENDING) + + val req = OnboardingRequest( + name = "홍길동", + school = "K대", + major = "컴공", + part = Part.BACKEND, + phoneNumber = "010-0000-0000", + fcmToken = "fcm-123" + ) + + val persistedMember = createMember(id = 1L, hasInfo = false, approval = ApprovalStatus.PENDING) + every { memberRepository.findById(1L) } returns Optional.of(persistedMember) + every { memberProfileRepository.findById(1L) } returns Optional.empty() + every { memberProfileRepository.save(any()) } answers { firstArg() } + every { memberService.markOnboarded(persistedMember) } just Runs + + val result = service.submitOnboarding(member, req) + + assertEquals(ApprovalStatus.PENDING, result.status) + + val slot = slot() + + every { memberProfileRepository.save(capture(slot)) } answers { slot.captured } + + service.submitOnboarding(member, req) + + // then + val saved = slot.captured + assertEquals("홍길동", saved.name) + assertEquals("K대", saved.school) + assertEquals("컴공", saved.major) + assertEquals(Part.BACKEND, saved.part) + assertEquals("010-0000-0000", saved.phoneNumber) + } + + @Test + fun `submitOnboarding - 이미 hasInfo true면 INVALID_MEMBER_STATE`() { + val member = createMember(id = 1L, hasInfo = true, approval = ApprovalStatus.PENDING) + + val req = OnboardingRequest( + name = "홍길동", + school = "K대", + major = "컴공", + part = Part.BACKEND, + phoneNumber = "010-0000-0000", + fcmToken = "fcm-123" + ) + + val ex = assertFailsWith { + service.submitOnboarding(member, req) + } + assertEquals(MemberErrorCode.INVALID_MEMBER_STATE, ex.errorCode) + } + + @Test + fun `submitOnboarding - approval이 PENDING이 아니면 INVALID_MEMBER_STATE`() { + val member = createMember(id = 1L, hasInfo = false, approval = ApprovalStatus.APPROVED) + + val req = OnboardingRequest( + name = "홍길동", + school = "K대", + major = "컴공", + part = Part.BACKEND, + phoneNumber = "010-0000-0000", + fcmToken = "fcm-123" + ) + + val ex = assertFailsWith { + service.submitOnboarding(member, req) + } + assertEquals(MemberErrorCode.INVALID_MEMBER_STATE, ex.errorCode) + } + + // getProfileSummary + + @Test + fun `getProfileSummary - 프로필과 포인트, 프로필 이미지 URL을 반환`() { + val member = createMember(id = 1L) + every { member.email } returns "user1@test.com" + + val profile = createProfile( + member = member, + name = "홍길동", + part = Part.BACKEND, + school = "K대", + major = "컴공", + phone = "010-0000-0000", + profileImage = "PROFILE/1/a.png" + ) + + every { memberProfileRepository.findById(1L) } returns Optional.of(profile) + + // sumPointsForMember projection mock + every { + memberPointHistoryRepository.sumPointsForMember(member) + } returns mockk { + every { getTotalPoints() } returns 80L + } + + every { + s3Service.getGetS3Url(1L, "PROFILE/1/a.png") + } returns GetS3UrlDto( + key = "PROFILE/1/a.png", + preSignedUrl = "https://example.com/profile1.png", + originalName = "a.png" + ) + + val resp = service.getProfileSummary(member) + + assertEquals("user1@test.com", resp.email) + assertEquals("홍길동", resp.name) + assertEquals(Part.BACKEND, resp.part) + assertEquals(80L, resp.totalPoints) + assertEquals("https://example.com/profile1.png", resp.profileImage) + } + + // issueProfileImageUploadUrl + + @Test + fun `issueProfileImageUploadUrl - 새 업로드 URL과 기존 삭제 URL을 반환`() { + val member = createMember(1L) + + // 기존 프로필: 이전 이미지 키 존재 + val profile = createProfile( + member = member, + profileImage = "PROFILE/1/old.png" + ) + every { memberProfileRepository.findById(1L) } returns Optional.of(profile) + + // 새 업로드용 pre-signed POST URL + every { + s3Service.getPostS3Url( + memberId = 1L, + filename = "new.png", + folderName = FolderName.MEMBER_PROFILE.name, + option = UploadOption.IMAGE + ) + } returns GetS3UrlDto( + key = "PROFILE/1/new.png", + preSignedUrl = "https://upload-url", + originalName = "new.png" + ) + + // 기존 삭제용 URL + every { + s3Service.getDeleteS3Url("PROFILE/1/old.png") + } returns GetS3UrlDto( + key = "PROFILE/1/old.png", + preSignedUrl = "https://delete-old", + originalName = "old.png" + ) + + val result: GetUpdateAndDeleteUrlDto = + service.issueProfileImageUploadUrl(member, "new.png") + + // newUrl, oldUrl 확인 + assertEquals("https://upload-url", result.newUrl) + assertEquals("https://delete-old", result.oldUrl) + + // submitProfileImage 안에서 profileImage 키가 바뀌었는지 + assertEquals("PROFILE/1/new.png", profile.profileImage) + + verify { + s3Service.getPostS3Url(1L, "new.png", FolderName.MEMBER_PROFILE.name, UploadOption.IMAGE) + s3Service.getDeleteS3Url("PROFILE/1/old.png") + } + } + // submitProfileImage + @Test + fun `submitProfileImage - old key를 반환하고 프로필 이미지 키를 바꾼다`() { + val member = createMember(1L) + val profile = createProfile(member = member, profileImage = "PROFILE/1/old.png") + + every { memberProfileRepository.findById(1L) } returns Optional.of(profile) + + val old = service.submitProfileImage(member, "PROFILE/1/new.png") + + assertEquals("PROFILE/1/old.png", old) + assertEquals("PROFILE/1/new.png", profile.profileImage) + } + + // updateProfile + + @Test + fun `updateProfile - 기본 정보 수정 후 basics 응답 반환`() { + val member = createMember(1L) + val profile = createProfile(member = member, name = "기존이름", profileImage = "PROFILE/1/a.png") + + every { memberRepository.findById(1L) } returns Optional.of(member) + every { memberProfileRepository.findById(1L) } returns Optional.of(profile) + + every { + s3Service.getGetS3Url(1L, "PROFILE/1/a.png") + } returns GetS3UrlDto( + key = "PROFILE/1/a.png", + preSignedUrl = "https://profile-url", + originalName = "a.png" + ) + + val req = MemberProfileUpdateRequest( + name = "새 이름", + school = "새 학교", + major = "새 전공", + part = Part.FRONTEND, + phoneNumber = "010-1111-2222" + ) + + val resp: MemberProfileBasicsResponse = service.updateProfile(1L, req) + + assertEquals("새 이름", resp.name) + assertEquals(Part.FRONTEND, resp.part) + assertEquals("새 학교", resp.school) + assertEquals("https://profile-url", resp.profileImageUrl) + + assertEquals("새 이름", profile.name) + assertEquals("새 학교", profile.school) + assertEquals("새 전공", profile.major) + assertEquals(Part.FRONTEND, profile.part) + assertEquals("010-1111-2222", profile.phoneNumber) + } + + // getApprovedMembersPagedWithCounts + + @Test + fun `getApprovedMembersPagedWithCounts - isStaff null이면 전체 승인 멤버 조회`() { + every { memberRepository.countByApproval(ApprovalStatus.PENDING) } returns 2L + every { memberRepository.countByApproval(ApprovalStatus.APPROVED) } returns 10L + every { memberRepository.countByApproval(ApprovalStatus.REJECTED) } returns 1L + + val member = createMember( + id = 1L, + isStaff = true, + approval = ApprovalStatus.APPROVED, + role = Role.USER, + socialType = SocialType.KAKAO + ) + + val profile = createProfile( + member = member, + name = "홍길동", + part = Part.BACKEND, + school = "K대", + major = "컴공", + phone = "010-0000-0000", + profileImage = "PROFILE/1/a.png" + ) + + every { + memberProfileRepository.findByMemberApproval( + ApprovalStatus.APPROVED, + any() + ) + } returns PageImpl(listOf(profile), PageRequest.of(0, 10), 1) + + every { + s3Service.getGetS3Url(1L, "PROFILE/1/a.png") + } returns GetS3UrlDto( + key = "PROFILE/1/a.png", + preSignedUrl = "https://profile-url", + originalName = "a.png" + ) + + val resp: MembersPagedResponse = + service.getApprovedMembersPagedWithCounts(page = 0, size = 10, isStaff = null) + + assertEquals(2L, resp.pendingCount) + assertEquals(10L, resp.approvedCount) + assertEquals(1L, resp.rejectedCount) + + assertEquals(1, resp.members.data.size) + val item = resp.members.data[0] + assertEquals(1L, item.memberId) + assertEquals("홍길동", item.name) + assertEquals(Part.BACKEND, item.part) + assertEquals("K대", item.school) + assertEquals("컴공", item.major) + assertEquals("010-0000-0000", item.phoneNumber) + assertTrue(item.isStaff) + assertEquals(ApprovalStatus.APPROVED, item.approval) + assertEquals("https://profile-url", item.profileImageUrl) + } + + // getApprovalRequestMembers + + @Test + fun `getApprovalRequestMembers - PENDING, REJECTED 상태 멤버 목록과 카운트 반환`() { + every { memberRepository.countByApproval(ApprovalStatus.PENDING) } returns 3L + every { memberRepository.countByApproval(ApprovalStatus.APPROVED) } returns 7L + every { memberRepository.countByApproval(ApprovalStatus.REJECTED) } returns 2L + + val member = createMember( + id = 1L, + isStaff = false, + approval = ApprovalStatus.PENDING + ) + + val profile = createProfile( + member = member, + name = "신청자", + part = Part.BACKEND, + school = "K대", + major = "컴공", + phone = "010-0000-1111", + profileImage = "PROFILE/1/a.png" + ) + + every { + memberProfileRepository.findByMemberApprovalIn( + listOf(ApprovalStatus.PENDING, ApprovalStatus.REJECTED), + any() + ) + } returns PageImpl(listOf(profile), PageRequest.of(0, 10), 1) + + every { + s3Service.getGetS3Url(1L, "PROFILE/1/a.png") + } returns GetS3UrlDto( + key = "PROFILE/1/a.png", + preSignedUrl = "https://profile-url", + originalName = "a.png" + ) + + val resp: MembersPagedResponse = + service.getApprovalRequestMembers(page = 0, size = 10) + + assertEquals(3L, resp.pendingCount) + assertEquals(7L, resp.approvedCount) + assertEquals(2L, resp.rejectedCount) + + assertEquals(1, resp.members.data.size) + val item = resp.members.data[0] + assertEquals(1L, item.memberId) + assertEquals("신청자", item.name) + assertEquals(ApprovalStatus.PENDING, item.approval) + assertEquals("https://profile-url", item.profileImageUrl) + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/member/service/MemberServiceTest.kt b/src/test/kotlin/onku/backend/member/service/MemberServiceTest.kt new file mode 100644 index 0000000..72ecaa6 --- /dev/null +++ b/src/test/kotlin/onku/backend/member/service/MemberServiceTest.kt @@ -0,0 +1,285 @@ +package onku.backend.member.service + +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import onku.backend.domain.member.Member +import onku.backend.domain.member.MemberErrorCode +import onku.backend.domain.member.MemberProfile +import onku.backend.domain.member.dto.StaffUpdateRequest +import onku.backend.domain.member.dto.UpdateRoleRequest +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Role +import onku.backend.domain.member.enums.SocialType +import onku.backend.domain.member.repository.MemberProfileRepository +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.domain.member.service.MemberService +import onku.backend.global.exception.CustomException +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.repository.findByIdOrNull +import java.util.* +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class MemberServiceTest { + + @MockK lateinit var memberRepository: MemberRepository + @MockK lateinit var memberProfileRepository: MemberProfileRepository + + lateinit var service: MemberService + + @BeforeEach + fun setUp() { + service = MemberService( + memberRepository = memberRepository, + memberProfileRepository = memberProfileRepository + ) + } + + private fun createMember( + id: Long = 1L, + email: String? = "test@aaa.com", + role: Role = Role.USER, + socialType: SocialType = SocialType.KAKAO, + socialId: Long = 123L, + hasInfo: Boolean = false, + approval: ApprovalStatus = ApprovalStatus.PENDING, + isStaff: Boolean = false + ): Member { + val m = mockk(relaxed = true) + every { m.id } returns id + every { m.email } returns email + every { m.role } returns role + every { m.socialType } returns socialType + every { m.socialId } returns socialId + every { m.hasInfo } returns hasInfo + every { m.approval } returns approval + every { m.isStaff } returns isStaff + + // mutable property 는 set 도 열어줘야 할 때 있음 + every { m.role = any() } just Runs + every { m.isStaff = any() } just Runs + + return m + } + + // getByEmail + @Test + fun `getByEmail - 존재하는 이메일이면 Member 리턴`() { + val member = createMember(id = 1L, email = "user@test.com") + + every { memberRepository.findByEmail("user@test.com") } returns member + + val result = service.getByEmail("user@test.com") + + assertEquals(1L, result.id) + assertEquals("user@test.com", result.email) + } + + @Test + fun `getByEmail - 없으면 예외 발생`() { + every { memberRepository.findByEmail("no@test.com") } returns null + + val ex = assertThrows(CustomException::class.java) { + service.getByEmail("no@test.com") + } + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + } + + // upsertSocialMember + @Test + fun `upsertSocialMember - 기존 소셜 계정이 있으면 업데이트 후 그대로 리턴`() { + val existing = createMember( + id = 1L, + email = "old@test.com", + socialId = 123L, + socialType = SocialType.KAKAO + ) + + every { memberRepository.findBySocialIdAndSocialType(123L, SocialType.KAKAO) } returns existing + every { existing.updateEmail("new@test.com") } just Runs + + val result = service.upsertSocialMember( + email = "new@test.com", + socialId = 123L, + type = SocialType.KAKAO + ) + + assertSame(existing, result) + verify(exactly = 0) { memberRepository.save(any()) } + verify(exactly = 1) { existing.updateEmail("new@test.com") } + } + + @Test + fun `upsertSocialMember - 기존 소셜 계정이 없으면 새로 생성`() { + every { memberRepository.findBySocialIdAndSocialType(123L, SocialType.KAKAO) } returns null + + val slotMember = slot() + every { memberRepository.save(capture(slotMember)) } answers { slotMember.captured } + + val result = service.upsertSocialMember( + email = "user@test.com", + socialId = 123L, + type = SocialType.KAKAO + ) + + // save 에 들어간 Member 검증 + assertEquals("user@test.com", slotMember.captured.email) + assertEquals(Role.USER, slotMember.captured.role) + assertEquals(SocialType.KAKAO, slotMember.captured.socialType) + assertEquals(123L, slotMember.captured.socialId) + assertFalse(slotMember.captured.hasInfo) + assertEquals(ApprovalStatus.PENDING, slotMember.captured.approval) + + assertSame(slotMember.captured, result) + } + + // markOnboarded + @Test + fun `markOnboarded - 존재하지 않으면 예외`() { + val member = createMember(id = 1L) + + every { memberRepository.findById(1L) } returns Optional.empty() + + val ex = assertThrows(CustomException::class.java) { + service.markOnboarded(member) + } + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + } + + @Test + fun `markOnboarded - hasInfo false면 onboarded 호출하고 save`() { + val m = createMember( + id = 1L, + hasInfo = false + ) + + every { memberRepository.findById(1L) } returns Optional.of(m) + every { m.onboarded() } just Runs + every { memberRepository.save(m) } returns m + + service.markOnboarded(m) + + verify { m.onboarded() } + verify { memberRepository.save(m) } + } + + @Test + fun `markOnboarded - 이미 hasInfo true면 아무것도 안 함`() { + val m = createMember( + id = 1L, + hasInfo = true + ) + + every { memberRepository.findById(1L) } returns Optional.of(m) + + service.markOnboarded(m) + + // onboarded, save 호출 안 됨 + verify(exactly = 0) { m.onboarded() } + verify(exactly = 0) { memberRepository.save(any()) } + } + + // deleteMemberById + @Test + fun `deleteMemberById - 존재하지 않으면 예외`() { + every { memberRepository.existsById(1L) } returns false + + val ex = assertThrows(CustomException::class.java) { + service.deleteMemberById(1L) + } + assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) + } + + @Test + fun `deleteMemberById - 프로필 있으면 프로필 먼저 삭제 후 멤버 삭제`() { + every { memberRepository.existsById(1L) } returns true + every { memberProfileRepository.existsByMember_Id(1L) } returns true + every { memberProfileRepository.deleteByMemberId(1L) } returns 1 + every { memberRepository.deleteById(1L) } just Runs + + service.deleteMemberById(1L) + + verifyOrder { + memberProfileRepository.existsByMember_Id(1L) + memberProfileRepository.deleteByMemberId(1L) + memberRepository.deleteById(1L) + } + } + + @Test + fun `deleteMemberById - 프로필 없으면 멤버만 삭제`() { + every { memberRepository.existsById(1L) } returns true + every { memberProfileRepository.existsByMember_Id(1L) } returns false + every { memberRepository.deleteById(1L) } just Runs + + service.deleteMemberById(1L) + + verify(exactly = 0) { memberProfileRepository.deleteByMemberId(any()) } + verify(exactly = 1) { memberRepository.deleteById(1L) } + } + + // updateRole + @Test + fun `updateRole - role이 null이면 예외`() { + val req = UpdateRoleRequest(role = null) + + val ex = assertThrows(CustomException::class.java) { + service.updateRole(1L, req) + } + assertEquals(MemberErrorCode.INVALID_REQUEST, ex.errorCode) + } + + @Test + fun `updateRole - 정상적으로 역할 변경`() { + val member = createMember(id = 1L, role = Role.USER) + + every { memberRepository.findByIdOrNull(1L) } returns member + every { memberRepository.save(member) } returns member + + val req = UpdateRoleRequest(role = Role.MANAGEMENT) + + service.updateRole(1L, req) + + verify { member.role = Role.MANAGEMENT } + verify { memberRepository.save(member) } + } + + // updateStaffMembers + @Test + fun `updateStaffMembers - 기존 운영진과 비교해 추가와 삭제 목록을 계산한다`() { + val staff1 = createMember(id = 1L, isStaff = true, role = Role.STAFF) + val staff2 = createMember(id = 2L, isStaff = true, role = Role.STAFF) + + // 현재 운영진: 1, 2 + every { memberRepository.findByIsStaffTrue() } returns listOf(staff1, staff2) + + // 요청: 2, 3 이 운영진이 되어야 함 + val req = StaffUpdateRequest( + staffMemberIds = listOf(2L, 3L) + ) + + // 추가 대상으로 3L 멤버가 있다고 가정 + val newStaff = createMember(id = 3L, isStaff = false, role = Role.USER) + every { memberRepository.findByIdIn(setOf(3L)) } returns listOf(newStaff) + + val result = service.updateStaffMembers(req) + + // 추가된 운영진: 3, 제거된 운영진: 1 + assertEquals(listOf(3L), result.addedStaffs) + assertEquals(listOf(1L), result.removedStaffs) + + // 3은 isStaff true, role STAFF 로 바뀌어야 함 + verify { newStaff.isStaff = true } + verify { newStaff.role = Role.STAFF } + + // 1은 isStaff false, role USER 로 바뀌어야 함 + verify { staff1.isStaff = false } + verify { staff1.role = Role.USER } + + // 2는 그대로 staff 유지 (바꾸지 않아도 됨) + verify(exactly = 0) { staff2.isStaff = false } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/point/service/AdminPointCommandServiceTest.kt b/src/test/kotlin/onku/backend/point/service/AdminPointCommandServiceTest.kt index 48d1436..ba8dd9e 100644 --- a/src/test/kotlin/onku/backend/point/service/AdminPointCommandServiceTest.kt +++ b/src/test/kotlin/onku/backend/point/service/AdminPointCommandServiceTest.kt @@ -73,9 +73,7 @@ class AdminPointCommandServiceTest { return m } - // ========================== // updateStudyPoints - // ========================== @Test fun `updateStudyPoints - 기존 레코드 있고 diff가 있으면 history 저장 + 값 갱신`() { @@ -143,9 +141,7 @@ class AdminPointCommandServiceTest { verify(exactly = 1) { memberPointHistoryRepository.save(any()) } // diff = 15 } - // ========================== // updateKupportersPoints & updateMemo - // ========================== @Test fun `updateKupportersPoints - kupporters 포인트 변경 시 history와 레코드 갱신`() { @@ -189,9 +185,7 @@ class AdminPointCommandServiceTest { verify(exactly = 0) { memberPointHistoryRepository.save(any()) } } - // ========================== - // updateIsTf & updateIsStaff - // ========================== + // updateIsTf @Test fun `updateIsTf - false에서 true로 토글되면 TF 포인트 추가되고 true 반환`() { @@ -217,6 +211,7 @@ class AdminPointCommandServiceTest { assertEquals(MemberErrorCode.MEMBER_NOT_FOUND, ex.errorCode) } + // updateIsStaff @Test fun `updateIsStaff - false에서 true로 토글되면 STAFF 포인트 추가`() { val member = createMember(1L) @@ -231,9 +226,7 @@ class AdminPointCommandServiceTest { verify(exactly = 1) { memberPointHistoryRepository.save(any()) } } - // ========================== // updateKupickApproval - // ========================== @Test fun `updateKupickApproval - 기존 Kupick이 있으면 approval 토글 및 history 기록`() { @@ -288,9 +281,7 @@ class AdminPointCommandServiceTest { assertEquals(KupickErrorCode.KUPICK_SAVE_FAILED, ex.errorCode) } - // ========================== // updateAttendanceAndHistory - // ========================== @Test fun `updateAttendanceAndHistory - attendance가 없으면 예외`() { diff --git a/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt b/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt index 5cf68a8..40ca37f 100644 --- a/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt +++ b/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt @@ -95,9 +95,7 @@ class AdminPointServiceTest { return p } - // ========================== // getAdminOverview - // ========================== @Test fun `getAdminOverview - 한 명에 대해 월별 출석, 쿠픽 참여, 수동 포인트를 잘 매핑한다`() { @@ -202,9 +200,7 @@ class AdminPointServiceTest { assertTrue(response.data.isEmpty()) } - // ========================== // getMonthlyPaged - // ========================== @Test fun `getMonthlyPaged - 해당 월 세션이 하나도 없으면 sessionDates와 members 비어있다`() { diff --git a/src/test/kotlin/onku/backend/point/service/MemberPointHistoryServiceTest.kt b/src/test/kotlin/onku/backend/point/service/MemberPointHistoryServiceTest.kt index f832020..66ac8ca 100644 --- a/src/test/kotlin/onku/backend/point/service/MemberPointHistoryServiceTest.kt +++ b/src/test/kotlin/onku/backend/point/service/MemberPointHistoryServiceTest.kt @@ -58,9 +58,7 @@ class MemberPointHistoryServiceTest { return p } - // ========================== // getHistory - // ========================== @Test fun `getHistory - 누적 포인트와 기록 페이지를 잘 매핑한다`() { @@ -72,7 +70,6 @@ class MemberPointHistoryServiceTest { val profile = createProfile(member, "김테스터") every { memberProfileRepository.findById(1L) } returns java.util.Optional.of(profile) - // 합계 projection mock val sums = mockk { every { getPlusPoints() } returns 100L every { getMinusPoints() } returns 20L @@ -133,9 +130,7 @@ class MemberPointHistoryServiceTest { verify(exactly = 0) { recordRepository.sumPointsForMember(any()) } } - // ========================== // upsertPointFromAbsenceReport - // ========================== @Test fun `upsertPointFromAbsenceReport - 기존 기록 없으면 새로 생성해서 저장`() { diff --git a/src/test/kotlin/onku/backend/session/service/SessionDetailServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionDetailServiceTest.kt index e18a5e9..1078746 100644 --- a/src/test/kotlin/onku/backend/session/service/SessionDetailServiceTest.kt +++ b/src/test/kotlin/onku/backend/session/service/SessionDetailServiceTest.kt @@ -47,9 +47,7 @@ class SessionDetailServiceTest { return s } - // ========================== - // 1) sessionDetailId 존재 → 기존 상세 수정 - // ========================== + // sessionDetailId 존재 → 기존 상세 수정 @Test fun `upsertSessionDetail - 기존 상세가 있으면 수정한다`() { val session = createSession(id = 100L) @@ -100,9 +98,7 @@ class SessionDetailServiceTest { // assertNotNull(eventSlot.captured.runAt) } - // ========================== - // 2) sessionDetailId 없음 → 새 상세 생성 - // ========================== + // sessionDetailId 없음 → 새 상세 생성 @Test fun `upsertSessionDetail - sessionDetailId가 없으면 새로 생성한다`() { val session = createSession(id = 200L) @@ -150,7 +146,7 @@ class SessionDetailServiceTest { verify { session.sessionDetail = savedDetail } verify { sessionRepository.save(session) } - // FinalizeEvent 발행 확인 (sessionId만 체크, runAt은 null 아님만 확인) + // FinalizeEvent 발행 확인 val eventSlot = slot() verify { applicationEventPublisher.publishEvent(capture(eventSlot)) } @@ -158,9 +154,7 @@ class SessionDetailServiceTest { assertNotNull(eventSlot.captured.runAt) } - // ========================== - // 3) sessionDetailId 있는데 DB에 없음 → 예외 - // ========================== + // sessionDetailId 있는데 DB에 없음 → 예외 @Test fun `upsertSessionDetail - 기존 상세 없으면 예외`() { val session = createSession(id = 300L) @@ -185,9 +179,7 @@ class SessionDetailServiceTest { verify(exactly = 0) { applicationEventPublisher.publishEvent(any()) } } - // ========================== - // 4) getById - 정상 - // ========================== + // getById - 정상 @Test fun `getById - 정상 조회`() { val detail = SessionDetail( @@ -206,9 +198,7 @@ class SessionDetailServiceTest { assertEquals("강의실", found.place) } - // ========================== - // 5) getById - 못 찾으면 예외 - // ========================== + // getById - 못 찾으면 예외 @Test fun `getById - 상세 없으면 예외`() { every { sessionDetailRepository.findById(100L) } returns Optional.empty() diff --git a/src/test/kotlin/onku/backend/session/service/SessionImageServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionImageServiceTest.kt index 2824d0a..fd74f08 100644 --- a/src/test/kotlin/onku/backend/session/service/SessionImageServiceTest.kt +++ b/src/test/kotlin/onku/backend/session/service/SessionImageServiceTest.kt @@ -40,9 +40,7 @@ class SessionImageServiceTest { return detail } - // ========================== // uploadImages - // ========================== @Test fun `uploadImages - SessionDetail과 url로 SessionImage를 생성하고 저장한다`() { val detail = createSessionDetail(1L) @@ -80,9 +78,7 @@ class SessionImageServiceTest { assertEquals(detail, result[0].sessionDetail) } - // ========================== // deleteImage - // ========================== @Test fun `deleteImage - id 기반으로 삭제 요청을 보낸다`() { val imageId = 10L @@ -94,9 +90,7 @@ class SessionImageServiceTest { verify(exactly = 1) { sessionImageRepository.deleteById(imageId) } } - // ========================== // getById - // ========================== @Test fun `getById - 이미지가 존재하면 반환한다`() { val imageId = 5L @@ -126,9 +120,7 @@ class SessionImageServiceTest { assertEquals(SessionErrorCode.SESSION_IMAGE_NOT_FOUND, ex.errorCode) } - // ========================== // findAllBySessionDetailId - // ========================== @Test fun `findAllBySessionDetailId - detailId로 모든 이미지를 조회한다`() { val detailId = 3L diff --git a/src/test/kotlin/onku/backend/session/service/SessionNoticeServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionNoticeServiceTest.kt index ef1166e..6f794fa 100644 --- a/src/test/kotlin/onku/backend/session/service/SessionNoticeServiceTest.kt +++ b/src/test/kotlin/onku/backend/session/service/SessionNoticeServiceTest.kt @@ -54,9 +54,7 @@ class SessionNoticeServiceTest { return d } - // ========================== - // 1) 정상 케이스 - // ========================== + // 정상 케이스 @Test fun `getSessionWithImages - 세션과 디테일, 이미지가 있으면 Triple을 반환한다`() { val sessionId = 1L @@ -87,9 +85,7 @@ class SessionNoticeServiceTest { verify(exactly = 1) { sessionImageRepository.findByDetailId(detailId) } } - // ========================== - // 2) 세션이 없을 때 - // ========================== + // 세션이 없을 때 @Test fun `getSessionWithImages - 세션이 없으면 SESSION_NOT_FOUND 예외`() { val sessionId = 99L @@ -105,9 +101,7 @@ class SessionNoticeServiceTest { verify(exactly = 0) { sessionImageRepository.findByDetailId(any()) } } - // ========================== - // 3) 디테일이 없을 때 - // ========================== + // 디테일이 없을 때 @Test fun `getSessionWithImages - sessionDetail이 없으면 SESSION_DETAIL_NOT_FOUND 예외`() { val sessionId = 1L diff --git a/src/test/kotlin/onku/backend/session/service/SessionServiceTest.kt b/src/test/kotlin/onku/backend/session/service/SessionServiceTest.kt index 25d1c04..734e0b2 100644 --- a/src/test/kotlin/onku/backend/session/service/SessionServiceTest.kt +++ b/src/test/kotlin/onku/backend/session/service/SessionServiceTest.kt @@ -83,9 +83,7 @@ class SessionServiceTest { return s } - // ========================== - // 1) getUpcomingSessionsForAbsence - // ========================== + // getUpcomingSessionsForAbsence @Test fun `getUpcomingSessionsForAbsence - 세션 목록과 active 여부를 반환한다`() { val now = LocalDateTime.now(clock) // 2025-01-01T10:00 @@ -119,9 +117,7 @@ class SessionServiceTest { verify { sessionValidator.isImminentSession(s2, now) } } - // ========================== - // 2) getById - // ========================== + // getById @Test fun `getById - 세션이 있으면 반환한다`() { val session = createSession(id = 10L) @@ -142,9 +138,7 @@ class SessionServiceTest { assertEquals(SessionErrorCode.SESSION_NOT_FOUND, ex.errorCode) } - // ========================== - // 3) saveAll - // ========================== + // saveAll @Test fun `saveAll - 요청 리스트를 Session 엔티티로 저장한다`() { val requests = listOf( @@ -189,9 +183,7 @@ class SessionServiceTest { assertTrue(saved[1].isHoliday) } - // ========================== - // 4) getInitialSession - // ========================== + // getInitialSession @Test fun `getInitialSession - 페이지로 세션을 조회하고 DTO로 매핑한다`() { val s1 = createSession( @@ -238,10 +230,7 @@ class SessionServiceTest { assertTrue(dto2.isHoliday) } - // ========================== - // 5) getThisWeekSession - // (날짜 range는 TimeRangeUtil에 맡기고, 여기선 repo 호출 + 매핑만 확인) - // ========================== + // getThisWeekSession @Test fun `getThisWeekSession - 이번주 세션 정보를 매핑해서 반환한다`() { val projection = mockk() @@ -271,9 +260,7 @@ class SessionServiceTest { verify { sessionRepository.findThisWeekSunToSat(any(), any()) } } - // ========================== - // 6) getAllSessionsOrderByStartDate - // ========================== + // getAllSessionsOrderByStartDate @Test fun `getAllSessionsOrderByStartDate - 모든 세션을 카드 정보로 반환한다`() { val s1 = createSession( @@ -309,9 +296,7 @@ class SessionServiceTest { assertEquals(LocalDate.of(2025, 1, 10), card2.startDate) } - // ========================== - // 7) deleteCascade - // ========================== + // deleteCascade @Test fun `deleteCascade - 세션이 없으면 예외`() { @@ -381,9 +366,7 @@ class SessionServiceTest { verify { sessionRepository.deleteById(sessionId) } } - // ========================== - // 8) getByDetailIdFetchDetail - // ========================== + // getByDetailIdFetchDetail @Test fun `getByDetailIdFetchDetail - 세션이 있으면 반환`() { From 8b9dd4106a63fa9747f1cb087c607240d6450b10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 25 Nov 2025 17:09:12 +0900 Subject: [PATCH 444/470] =?UTF-8?q?fix=20:=20[=EB=A6=AC=EB=B7=B0=EB=B0=98?= =?UTF-8?q?=EC=98=81]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=B4=EB=9E=91=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=A7=9E=EC=B6=94=EA=B8=B0=20=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/attendance/service/AttendanceServiceTest.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt b/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt index 4468f22..94d37c6 100644 --- a/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt +++ b/src/test/kotlin/onku/backend/attendance/service/AttendanceServiceTest.kt @@ -157,7 +157,8 @@ class AttendanceServiceTest { // SessionTimeUtil.startDateTime(session) mock mockkObject(SessionTimeUtil) - every { SessionTimeUtil.startDateTime(session) } returns now + every { SessionTimeUtil.startDateTime(session) } returns now.plusMinutes(5) + // insertOnly: 어떤 값이 오든 1 리턴 every { @@ -186,14 +187,14 @@ class AttendanceServiceTest { // then assertEquals(member.id!!, response.memberId) assertEquals(session.id!!, response.sessionId) - assertEquals(AttendancePointType.LATE, response.state) + assertEquals(AttendancePointType.PRESENT, response.state) assertEquals(now, response.scannedAt) verify(exactly = 1) { attendanceRepository.insertOnly( any(), // sessionId any(), // memberId - AttendancePointType.LATE.name, + AttendancePointType.PRESENT.name, any(), any(), any() ) } From 0c9e917a5d4b31b0b08fe179acfb4bf3dba19ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 25 Nov 2025 17:12:02 +0900 Subject: [PATCH 445/470] =?UTF-8?q?chore=20:=20[=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EB=B0=98=EC=98=81]=20=EC=98=A4=ED=83=88=EC=9E=90=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/member/service/MemberAlarmHistoryServiceTest.kt | 2 +- .../kotlin/onku/backend/point/service/AdminPointServiceTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/onku/backend/member/service/MemberAlarmHistoryServiceTest.kt b/src/test/kotlin/onku/backend/member/service/MemberAlarmHistoryServiceTest.kt index 79ba4ac..03f2121 100644 --- a/src/test/kotlin/onku/backend/member/service/MemberAlarmHistoryServiceTest.kt +++ b/src/test/kotlin/onku/backend/member/service/MemberAlarmHistoryServiceTest.kt @@ -85,7 +85,7 @@ class MemberAlarmHistoryServiceTest { // given val member = createMember(1L) val type = AlarmEmojiType.STAR - val message = "쿠픽 승인 되었습니다." + val message = "큪픽 승인 되었습니다." val slot = slot() every { memberAlarmHistoryRepository.save(capture(slot)) } answers { diff --git a/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt b/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt index 40ca37f..e196fcd 100644 --- a/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt +++ b/src/test/kotlin/onku/backend/point/service/AdminPointServiceTest.kt @@ -98,7 +98,7 @@ class AdminPointServiceTest { // getAdminOverview @Test - fun `getAdminOverview - 한 명에 대해 월별 출석, 쿠픽 참여, 수동 포인트를 잘 매핑한다`() { + fun `getAdminOverview - 한 명에 대해 월별 출석, 큐픽 참여, 수동 포인트를 잘 매핑한다`() { val year = 2025 val page = 0 val size = 10 From 9058afe8b57f6c0a7b0b42939c2b17504bea3559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 25 Nov 2025 17:15:14 +0900 Subject: [PATCH 446/470] =?UTF-8?q?chore=20:=20[=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EB=B0=98=EC=98=81]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=9B=84?= =?UTF-8?q?=EC=97=90=20mock=EA=B0=9D=EC=B2=B4=20=ED=95=B4=EC=A0=9C=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/absence/service/AbsenceServiceTest.kt | 6 ++---- .../attendance/service/AttendanceFinalizeServiceTest.kt | 6 ++++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt b/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt index 367711a..653fe8a 100644 --- a/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt +++ b/src/test/kotlin/onku/backend/absence/service/AbsenceServiceTest.kt @@ -1,11 +1,8 @@ package onku.backend.absence.service -import io.mockk.every +import io.mockk.* import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.verify import onku.backend.domain.absence.AbsenceReport import onku.backend.domain.absence.AbsenceReportErrorCode import onku.backend.domain.absence.dto.request.SubmitAbsenceReportRequest @@ -77,6 +74,7 @@ class AbsenceServiceTest { AbsenceReport.createAbsenceReport(member, session, request, fileKey) } verify(exactly = 1) { absenceReportRepository.save(createdReport) } + unmockkObject(AbsenceReport) } diff --git a/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt b/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt index d56c565..2fe16f9 100644 --- a/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt +++ b/src/test/kotlin/onku/backend/attendance/service/AttendanceFinalizeServiceTest.kt @@ -12,6 +12,7 @@ import onku.backend.domain.point.repository.MemberPointHistoryRepository import onku.backend.domain.session.Session import onku.backend.domain.session.repository.SessionRepository import onku.backend.domain.session.util.SessionTimeUtil +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.extension.ExtendWith import java.time.Clock @@ -66,6 +67,11 @@ class AttendanceFinalizeServiceTest { every { SessionTimeUtil.startDateTime(any()) } returns LocalDateTime.of(2025, 1, 1, 10, 0) } + @AfterEach + fun afterTest() { + unmockkStatic(SessionTimeUtil::class) + } + private fun createSession( id: Long = 1L, week: Long = 1L, From 88fd417ba81f4aff0c0d8552216a08f7cf9e3d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Tue, 25 Nov 2025 17:15:48 +0900 Subject: [PATCH 447/470] =?UTF-8?q?chore=20:=20[=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EB=B0=98=EC=98=81]=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EA=B5=AC=EB=AC=B8=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt b/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt index 0f8e553..705a21f 100644 --- a/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt +++ b/src/test/kotlin/onku/backend/kupick/service/KupickServiceTest.kt @@ -16,7 +16,6 @@ import onku.backend.global.exception.CustomException import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.Mockito.verify import kotlin.test.Test import kotlin.test.assertFailsWith From f0448aaef9a2b1df6d7fbe8d5271afd6d437cdbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 26 Nov 2025 13:06:20 +0900 Subject: [PATCH 448/470] =?UTF-8?q?refactor=20:=20=EB=B6=88=EC=B0=B8?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EC=84=9C=20=EC=A6=9D=EB=B9=99=EC=84=9C?= =?UTF-8?q?=EB=A5=98=20=EC=84=A0=ED=83=9D=EC=82=AC=ED=95=AD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20#161?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/absence/dto/request/SubmitAbsenceReportRequest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6d6bb1b..aa810ba 100644 --- a/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt +++ b/src/main/kotlin/onku/backend/domain/absence/dto/request/SubmitAbsenceReportRequest.kt @@ -10,7 +10,7 @@ data class SubmitAbsenceReportRequest( @field:NotNull val sessionId : Long, val submitType : AbsenceSubmitType, val reason : String, - val fileName : String, + val fileName : String?, val lateDateTime : LocalDateTime?, val leaveDateTime : LocalDateTime? ) \ No newline at end of file From 810e2eb247a5305de3689fad6888d0e92d1f8adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Wed, 26 Nov 2025 22:33:59 +0900 Subject: [PATCH 449/470] =?UTF-8?q?feat=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20api=20=EC=82=AD=EC=A0=9C=20#163?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/user/MemberUserController.kt | 13 ---- .../staff/SessionStaffController.kt | 12 ---- .../controller/user/SessionController.kt | 18 ------ .../backend/domain/test/TestController.kt | 64 ------------------- .../onku/backend/domain/test/dto/TestDto.kt | 11 ---- .../global/s3/controller/S3Controller.kt | 39 ----------- 6 files changed, 157 deletions(-) delete mode 100644 src/main/kotlin/onku/backend/domain/test/TestController.kt delete mode 100644 src/main/kotlin/onku/backend/domain/test/dto/TestDto.kt delete mode 100644 src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt 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 index b26204f..dc5b4bf 100644 --- a/src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt +++ b/src/main/kotlin/onku/backend/domain/member/controller/user/MemberUserController.kt @@ -63,19 +63,6 @@ class MemberUserController( return ResponseEntity.ok(SuccessResponse.ok(dto)) } - @PatchMapping("/{memberId}/role") - @Operation( - summary = "[QA] 사용자 권한 수정", - description = "memberId 에 해당하는 사용자의 권한을 role 로 수정합니다. QA 혹은 프론트엔드 연동 기간동안 권한 제한 없이 자유롭게 사용해주시면 됩니다!" - ) - fun updateRole( - @PathVariable memberId: Long, - @RequestBody @Valid req: UpdateRoleRequest - ): ResponseEntity> { - val body = memberService.updateRole(memberId, req) - return ResponseEntity.ok(SuccessResponse.ok(body)) - } - @GetMapping("/alarms") @Operation( summary = "내 알림 히스토리 조회", 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 index 8e9d20e..45a5ba5 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/staff/SessionStaffController.kt @@ -95,18 +95,6 @@ class SessionStaffController( return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.getSessionDetailPage(detailId))) } - @DeleteMapping("/{id}") - @Operation( - summary = "세션 삭제 [TEMP]", - description = "세션 ID로 세션과 상세, 연결된 모든 이미지를 삭제합니다. (S3에 저장된 이미지도 즉시 삭제)" - ) - fun deleteSession( - @PathVariable id: Long - ): ResponseEntity> { - sessionFacade.deleteSession(id) - return ResponseEntity.ok(SuccessResponse.ok(true)) - } - @PatchMapping("/{id}") @Operation( summary = "세션 수정", 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 index 4311319..9fd85bd 100644 --- a/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt +++ b/src/main/kotlin/onku/backend/domain/session/controller/user/SessionController.kt @@ -52,22 +52,4 @@ class SessionController( ) : ResponseEntity> { return ResponseEntity.ok(SuccessResponse.ok(sessionFacade.getSessionNotice(sessionId))) } - - @PatchMapping("/{sessionId}/time") - @Operation( - summary = "출석체크가 가능하도록 세션 시간 수정 [TEMP]", - description = """ - 1. Try it out 버튼 클릭 - 2. (금주 세션 id를 모른다면 GET /api/v1/session/this-week 에서 sessionId 확인!) - 3. 수정하고자 하는 세션 id 입력 - 4. Execute 버튼 눌러서 요청 보내기 - 5. 완료! 현재 시각 기준 20분 이후까지 출석이 가능하도록 세션 시간 및 기타 세션 정보들이 설정됨 - """ - ) - fun resetSessionTime( - @PathVariable sessionId: Long - ): ResponseEntity> { - val body = sessionFacade.resetSessionTime(sessionId) - return ResponseEntity.ok(SuccessResponse.ok(body)) - } } \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/test/TestController.kt b/src/main/kotlin/onku/backend/domain/test/TestController.kt deleted file mode 100644 index ba6b6cb..0000000 --- a/src/main/kotlin/onku/backend/domain/test/TestController.kt +++ /dev/null @@ -1,64 +0,0 @@ -package onku.backend.domain.test - -import io.swagger.v3.oas.annotations.Hidden -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag -import jakarta.validation.Valid -import jakarta.validation.constraints.Min -import onku.backend.domain.member.Member -import onku.backend.domain.test.dto.TestDto -import onku.backend.global.annotation.CurrentMember -import onku.backend.global.exception.CustomException -import onku.backend.global.exception.ErrorCode -import onku.backend.global.response.SuccessResponse -import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.* - -@Hidden -@RestController -@RequestMapping("/test") -@Tag(name = "테스트용 API") -class TestController { - @ResponseStatus(HttpStatus.OK) - @GetMapping("/health") - @Operation(summary = "헬스 체크", description = "health") - fun health(): String { - return "OK" - } - - @PostMapping("/request-body") - @ResponseStatus(HttpStatus.OK) - @Operation(summary = "body 검증 테스트", description = "TestDto의 count의 @Min 조건 검증") - fun beanValidation(@RequestBody @Valid body: TestDto): SuccessResponse = - SuccessResponse.ok("VALID") - - @GetMapping("/param") - @ResponseStatus(HttpStatus.OK) - @Operation(summary = "Param 검증 테스트", description = "@RequestParam의 @Min 조건 검증") - fun paramValidation( - @RequestParam @Min(1, message = "age는 1 이상이어야 합니다") age: Int - ): SuccessResponse = - SuccessResponse.ok("AGE=$age") - - @PostMapping("/json") - @ResponseStatus(HttpStatus.OK) - @Operation(summary = "JSON 문법 에러 검증 테스트", description = "콤마 누락 등의 문법오류와 필드의 타입오류 검증") - fun jsonGrammar(@RequestBody body: TestDto): SuccessResponse = - SuccessResponse.ok("PARSED") - - @GetMapping("/custom-error") - @Operation(summary = "커스텀 예외 테스트") - fun custom(): String { - throw CustomException(ErrorCode.INVALID_REQUEST) - } - - @GetMapping("/untracked-error") - @Operation(summary = "미등록 예외 테스트") - fun untracked(): String { - throw IllegalStateException("테스트용 미등록 예외") - } - - @GetMapping("/annotation") - fun annotation(@CurrentMember member: Member): SuccessResponse = - SuccessResponse.ok("MEMBER_ID=$member.id") -} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/domain/test/dto/TestDto.kt b/src/main/kotlin/onku/backend/domain/test/dto/TestDto.kt deleted file mode 100644 index 6d139fa..0000000 --- a/src/main/kotlin/onku/backend/domain/test/dto/TestDto.kt +++ /dev/null @@ -1,11 +0,0 @@ -package onku.backend.domain.test.dto - -import jakarta.validation.constraints.* - -data class TestDto( - @field:NotBlank(message = "title은 공백일 수 없습니다") - val title: String?, - - @field:Min(value = 1, message = "count는 1 이상이어야 합니다") - val count: Int? -) diff --git a/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt b/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt deleted file mode 100644 index ec03caa..0000000 --- a/src/main/kotlin/onku/backend/global/s3/controller/S3Controller.kt +++ /dev/null @@ -1,39 +0,0 @@ -package onku.backend.global.s3.controller - -import io.swagger.v3.oas.annotations.Hidden -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.response.SuccessResponse -import onku.backend.global.s3.dto.GetS3UrlDto -import onku.backend.global.s3.enums.UploadOption -import onku.backend.global.s3.service.S3Service -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@Tag(name = "S3 API", description = "Presigned Url 발급 API") -@RestController -@RequestMapping("/api/v1/s3") -class S3Controller( - private val s3Service : S3Service -) { - /** - * Todo 테스트용 컨트롤러임 나중에 지우기 - */ - @Hidden - @GetMapping("/postUrl") - @Operation(summary = "업로드 용 postUrl", description = "업로드 용 PresignedUrl을 반환합니다.") - fun postUrl(@CurrentMember member : Member, folderName : String, fileName : String) : ResponseEntity> { - return ResponseEntity.ok(SuccessResponse.ok(s3Service.getPostS3Url(member.id!!, fileName, folderName, UploadOption.FILE))) - } - - @Hidden - @GetMapping("/getUrl") - @Operation(summary = "조회 용 getUrl", description = "조회 용 PresignedUrl을 반환합니다.") - fun getUrl(@CurrentMember member : Member, keyName: String) : ResponseEntity> { - return ResponseEntity.ok(SuccessResponse.ok(s3Service.getGetS3Url(member.id!!, keyName))) - } -} \ No newline at end of file From d7f01746c5ee8cb9baac8415580c30949def9f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 27 Nov 2025 14:28:51 +0900 Subject: [PATCH 450/470] =?UTF-8?q?fix=20:=20=EC=A1=B0=ED=9A=8C=EC=8B=9C?= =?UTF-8?q?=20heic=ED=99=95=EC=9E=A5=EC=9E=90=EB=8F=84=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=20#165?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/s3/service/S3Service.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt index 4d97269..07e0148 100644 --- a/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt +++ b/src/main/kotlin/onku/backend/global/s3/service/S3Service.kt @@ -130,6 +130,7 @@ class S3Service( lower.endsWith(".gif") -> "image/gif" lower.endsWith(".webp") -> "image/webp" lower.endsWith(".pdf") -> "application/pdf" + lower.endsWith(".heic") -> "image/heic" else -> throw CustomException(ErrorCode.INVALID_FILE_EXTENSION) } } From cf884b02f50266eb3d37a9241306fc6d26ddfbb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Thu, 4 Dec 2025 12:47:28 +0900 Subject: [PATCH 451/470] =?UTF-8?q?chore:=20README=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EC=B6=94=EA=B0=80=20#167?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..97f35b8 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# 큐-첵 (Ku-check): 큐시즘의 모든 일을 한큐에 체크하다, 큐첵 +> 🔗 **서비스 링크**: 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` | 개발 환경 세팅| \ No newline at end of file From 3fe0bd8b8d8c9d0300223b852d773a8ce8605a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= <126869805+minzix@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:56:47 +0900 Subject: [PATCH 452/470] =?UTF-8?q?chore:=20README=20=EC=B2=A8=EB=B6=80=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20#16?= =?UTF-8?q?7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 97f35b8..7bfc0d5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # 큐-첵 (Ku-check): 큐시즘의 모든 일을 한큐에 체크하다, 큐첵 -> 🔗 **서비스 링크**: https://ku-check.vercel.app +> 🔗 **서비스 링크**: [`https://ku-check.vercel.app`](https://ku-check.vercel.app) +큐첵 소개 ## 🎨 서비스 설명 - 큐첵은 큐시즘 운영에 흩어져 있던 출결, 상벌점, 공지, 불참사유서 제출을 하나로 통합해 학회 운영을 더 정확하고 간편하게 만드는 전용 관리 서비스입니다. @@ -15,6 +16,7 @@ [큐첵 개발 서버 API 명세서](https://dev.ku-check.o-r.kr/swagger-ui/index.html) ## 🗃️ ERD +Ku-Check (1) (1) ## 🛠️ 기술 스택 | 기술 스택 | 사용 이유 | @@ -44,4 +46,4 @@ | `test` | 테스트 코드 추가 및 수정, 삭제 | | `refactor` | 코드 리팩토링 | | `deploy` | 배포 관련 작업 (CI/CD, 서버 설정, 배포 스크립트 등) | -| `setting` | 개발 환경 세팅| \ No newline at end of file +| `setting` | 개발 환경 세팅| From 1aa14cbe0dbfd858ed8baf39e228d1777a7482d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 4 Dec 2025 14:32:57 +0900 Subject: [PATCH 453/470] =?UTF-8?q?fix=20:=20prod=20=EB=A0=88=EB=94=94?= =?UTF-8?q?=EC=8A=A4=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=95=88=20?= =?UTF-8?q?=EB=93=A4=EC=96=B4=EA=B0=80=EB=8A=94=20=ED=98=84=EC=83=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8a9d909..54ecc84 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -189,7 +189,7 @@ jobs: port: ${{ secrets.PROD_EC2_PORT }} script: | export DOCKER_CONTAINER_REGISTRY=${{ secrets.DOCKERHUB_USERNAME }} - export REDIS_PASSWORD=${{ secrets.PROD_REDIS_PASSWORD }} + export REDIS_PASSWORD='${{ secrets.PROD_REDIS_PASSWORD }}' export GITHUB_SHA=${{ github.sha }} sudo chmod +x ./deploy.sh ./deploy.sh From d012e7d9e9cb41c67791d8798fdf84b561f6de3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 18 Dec 2025 13:39:07 +0900 Subject: [PATCH 454/470] =?UTF-8?q?feat=20:=20aes=EC=95=99=ED=98=B8?= =?UTF-8?q?=ED=99=94=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20#170?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/crypto/AESService.kt | 67 +++++++++++++++++++ .../backend/global/crypto/PrivacyEncryptor.kt | 8 +++ 2 files changed, 75 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/crypto/AESService.kt create mode 100644 src/main/kotlin/onku/backend/global/crypto/PrivacyEncryptor.kt 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..22734ab --- /dev/null +++ b/src/main/kotlin/onku/backend/global/crypto/AESService.kt @@ -0,0 +1,67 @@ +package onku.backend.global.crypto + +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 IllegalStateException("AES-GCM encrypt failed", 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) { "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 IllegalStateException("AES-GCM decrypt failed", 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 From 12e51887ced7dca0293c0b36404c5c7c75da92dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 18 Dec 2025 13:39:42 +0900 Subject: [PATCH 455/470] =?UTF-8?q?feat=20:=20jpa=EC=BB=A8=EB=B2=84?= =?UTF-8?q?=ED=84=B0=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1=20#170?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/context/SpringContext.kt | 20 ++++++++++++ .../converter/EncryptedStringConverter.kt | 32 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/context/SpringContext.kt create mode 100644 src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt 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/converter/EncryptedStringConverter.kt b/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt new file mode 100644 index 0000000..6141ff6 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt @@ -0,0 +1,32 @@ +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 + + +@Converter +class EncryptedStringConverter : AttributeConverter { + private fun encryptor(): PrivacyEncryptor { + return SpringContext.getBean(PrivacyEncryptor::class.java) + } + + override fun convertToDatabaseColumn(raw: String?): String? { + if (raw == null) return null + try { + return encryptor().encrypt(raw) + } catch (e: Exception) { + throw IllegalStateException(e) + } + } + + override fun convertToEntityAttribute(encrypted: String?): String? { + if (encrypted == null) return null + try { + return encryptor().decrypt(encrypted) + } catch (e: Exception) { + throw IllegalStateException(e) + } + } +} From a6ac4063eb8208de03f017a0c383cb0d23022040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 18 Dec 2025 13:40:04 +0900 Subject: [PATCH 456/470] =?UTF-8?q?feat=20:=20aes=EC=95=94=EB=B3=B5?= =?UTF-8?q?=ED=98=B8=ED=99=94=20=EC=A0=81=EC=9A=A9=20#170?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/onku/backend/domain/member/MemberProfile.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt b/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt index 7f884f0..d40cf57 100644 --- a/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt +++ b/src/main/kotlin/onku/backend/domain/member/MemberProfile.kt @@ -2,6 +2,7 @@ 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( @@ -14,7 +15,8 @@ class MemberProfile( @JoinColumn(name = "member_id") val member: Member, - @Column(length = 100) + @Column(length = 512) + @Convert(converter = EncryptedStringConverter::class) var name: String? = null, @Column(length = 100) @@ -27,10 +29,12 @@ class MemberProfile( @Enumerated(EnumType.STRING) var part: Part, - @Column(name = "phone_number", length = 30) + @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( From 9c2e4d0b2f5d31a94d4d6fbbc91cc81a1c7d9f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 18 Dec 2025 23:18:32 +0900 Subject: [PATCH 457/470] =?UTF-8?q?refactor=20:=20=EC=95=94=EB=B3=B5?= =?UTF-8?q?=ED=98=B8=ED=99=94=20=EC=97=90=EB=9F=AC=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20#170?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/crypto/AESService.kt | 6 +- .../converter/EncryptedStringConverter.kt | 6 +- .../crypto/exception/CryptoException.kt | 6 ++ .../global/exception/ExceptionAdvice.kt | 66 +++++++++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/onku/backend/global/crypto/exception/CryptoException.kt diff --git a/src/main/kotlin/onku/backend/global/crypto/AESService.kt b/src/main/kotlin/onku/backend/global/crypto/AESService.kt index 22734ab..cc8d223 100644 --- a/src/main/kotlin/onku/backend/global/crypto/AESService.kt +++ b/src/main/kotlin/onku/backend/global/crypto/AESService.kt @@ -1,5 +1,7 @@ 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 @@ -43,7 +45,7 @@ class AESService( val cipherText = cipher.doFinal(raw.toByteArray(StandardCharsets.UTF_8)) Base64.getEncoder().encodeToString(iv + cipherText) } catch (e: Exception) { - throw IllegalStateException("AES-GCM encrypt failed", e) + throw EncryptionException(e) } } @@ -61,7 +63,7 @@ class AESService( } String(cipher.doFinal(cipherText), StandardCharsets.UTF_8) } catch (e: Exception) { - throw IllegalStateException("AES-GCM decrypt failed", e) + throw DecryptionException(e) } } } diff --git a/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt b/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt index 6141ff6..9ffffe7 100644 --- a/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt +++ b/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt @@ -4,6 +4,8 @@ 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 @@ -17,7 +19,7 @@ class EncryptedStringConverter : AttributeConverter { try { return encryptor().encrypt(raw) } catch (e: Exception) { - throw IllegalStateException(e) + throw EncryptionException(e) } } @@ -26,7 +28,7 @@ class EncryptedStringConverter : AttributeConverter { try { return encryptor().decrypt(encrypted) } catch (e: Exception) { - throw IllegalStateException(e) + 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/exception/ExceptionAdvice.kt b/src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt index b65d762..df5a16d 100644 --- a/src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt +++ b/src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt @@ -2,8 +2,10 @@ 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 @@ -16,6 +18,9 @@ import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice class ExceptionAdvice { + private val log = LoggerFactory.getLogger(javaClass) + + /** * 등록되지 않은 에러 */ @@ -132,4 +137,65 @@ class ExceptionAdvice { return ResponseEntity(body, code.status) } + /** + * 암호화 관련 에러 + */ + @ExceptionHandler(CryptoException::class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + fun handleCryptoException(e: CryptoException, req: HttpServletRequest): ErrorResponse { + + // ✅ 민감정보(raw/encrypted) 절대 로그에 찍지 말기 + // ✅ 대신 요청정보/경로/메서드/원인 예외 타입 정도만 + log.error( + "[CRYPTO_ERROR] {} {} uri={} remote={} ua={} cause={}", + req.method, + req.requestURI, + req.requestURL, + req.remoteAddr, + req.getHeader("User-Agent"), + e.cause?.javaClass?.name ?: "none", + e + ) + + val serverErrorData = ExceptionResult.ServerErrorData( + errorClass = null, + errorMessage = "internal server error" // 암호화 관련 에러인건 프론트에 숨기기 + ) + + return ErrorResponse.ok( + ErrorCode.SERVER_UNTRACKED_ERROR.errorCode, + ErrorCode.SERVER_UNTRACKED_ERROR.message, + serverErrorData + ) + } + + @ExceptionHandler(org.springframework.orm.jpa.JpaSystemException::class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + fun handleJpaSystemException( + e: org.springframework.orm.jpa.JpaSystemException, + req: HttpServletRequest + ): ErrorResponse { + + val isConverter = e.message?.contains("AttributeConverter", ignoreCase = true) == true + + if (isConverter) { + log.error("[CRYPTO_ERROR][JPA_CONVERTER] {} {} class={} msg={}", + req.method, req.requestURI, e.javaClass.name, e.message, e + ) + } else { + log.error("[JPA_ERROR] {} {}", req.method, req.requestURI, e) + } + + val serverErrorData = ExceptionResult.ServerErrorData( + errorClass = "INTERNAL_SERVER_ERROR", + errorMessage = "internal server error" + ) + + return ErrorResponse.ok( + ErrorCode.SERVER_UNTRACKED_ERROR.errorCode, + ErrorCode.SERVER_UNTRACKED_ERROR.message, + serverErrorData + ) + } + } From a1d7f270394039fd779d8861b84bf22453ad838b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 18 Dec 2025 23:18:52 +0900 Subject: [PATCH 458/470] =?UTF-8?q?test=20:=20=EC=95=94=EB=B3=B5=ED=98=B8?= =?UTF-8?q?=ED=99=94=20=EC=97=90=EB=9F=AC=20=EA=B4=80=EB=A0=A8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#170?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/crypto/CryptoErrorTest.kt | 46 ++++++++++++++ .../onku/backend/crypto/CryptoJpaErrorTest.kt | 63 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/test/kotlin/onku/backend/crypto/CryptoErrorTest.kt create mode 100644 src/test/kotlin/onku/backend/crypto/CryptoJpaErrorTest.kt diff --git a/src/test/kotlin/onku/backend/crypto/CryptoErrorTest.kt b/src/test/kotlin/onku/backend/crypto/CryptoErrorTest.kt new file mode 100644 index 0000000..aa8086e --- /dev/null +++ b/src/test/kotlin/onku/backend/crypto/CryptoErrorTest.kt @@ -0,0 +1,46 @@ +package onku.backend.crypto + +import onku.backend.global.crypto.AESService +import onku.backend.global.crypto.exception.DecryptionException +import org.junit.jupiter.api.Assertions.assertThrows +import java.util.* +import kotlin.test.Test + +class CryptoErrorTest { + private fun keyA(): String = + Base64.getEncoder().encodeToString(ByteArray(32) { 1 }) + + private fun keyB(): String = + Base64.getEncoder().encodeToString(ByteArray(32) { 2 }) + + @Test + fun `decrypt - invalid base64 throws DecryptionException`() { + val aes = AESService(keyA()) + + assertThrows(DecryptionException::class.java) { + aes.decrypt("%%%not-base64%%%") + } + } + + @Test + fun `decrypt - too short ciphertext throws DecryptionException`() { + val aes = AESService(keyA()) + + val tooShort = Base64.getEncoder().encodeToString(byteArrayOf(0x01)) + assertThrows(DecryptionException::class.java) { + aes.decrypt(tooShort) + } + } + + @Test + fun `decrypt - wrong key throws DecryptionException`() { + val encryptor = AESService(keyA()) + val decryptor = AESService(keyB()) + + val cipher = encryptor.encrypt("hello")!! + + assertThrows(DecryptionException::class.java) { + decryptor.decrypt(cipher) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/onku/backend/crypto/CryptoJpaErrorTest.kt b/src/test/kotlin/onku/backend/crypto/CryptoJpaErrorTest.kt new file mode 100644 index 0000000..0285fd2 --- /dev/null +++ b/src/test/kotlin/onku/backend/crypto/CryptoJpaErrorTest.kt @@ -0,0 +1,63 @@ +package onku.backend.crypto + + +import onku.backend.domain.absence.enums.AbsenceApprovedType +import onku.backend.domain.member.enums.ApprovalStatus +import onku.backend.domain.member.enums.Role +import onku.backend.domain.member.enums.SocialType +import onku.backend.domain.member.repository.MemberRepository +import onku.backend.global.crypto.exception.DecryptionException +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.orm.jpa.JpaSystemException +import org.springframework.test.context.ActiveProfiles +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import kotlin.test.Test + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class CryptoJpaErrorTest( + @Autowired private val jdbcTemplate: JdbcTemplate, + @Autowired private val memberRepository: MemberRepository, +) { + + @Test + fun `jpa decrypt error is wrapped as JpaSystemException`() { + // ✅ 1) DB에 "깨진 암호문"을 직접 넣기 (Base64 아님) + jdbcTemplate.update( + "insert into MEMBER (member_id, email, role, social_type, social_id, has_info, approval, is_tf, is_staff, created_at, updated_at) values (?,?,?,?,?,?,?,?,?,?,?)", + 1L, + "abc@def.com", + Role.USER.name, + SocialType.KAKAO.name, + 1234567890, + false, + ApprovalStatus.APPROVED.name, + false, + false, + LocalDateTime.now(), + LocalDateTime.now() + ) + jdbcTemplate.update( + "insert into MEMBER_PROFILE (member_id, name) values (?, ?)", + 1L, + "%%%not-base64%%%" + ) + + // ✅ 2) 조회 시 Converter decrypt 실행 -> 예외 -> JPA 래핑 + val ex = assertThrows(JpaSystemException::class.java) { + memberRepository.findById(1L).orElseThrow() + } + + // ✅ 3) 원인 체인에 DecryptionException이 있는지 확인 + assertTrue(ex.hasCause(DecryptionException::class.java)) + } + + private fun Throwable.hasCause(type: Class<*>): Boolean = + generateSequence(this) { it.cause }.any { type.isInstance(it) } +} \ No newline at end of file From 015e90164c334d94ff08b5ed57e101ade90b41b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sun, 28 Dec 2025 15:45:18 +0900 Subject: [PATCH 459/470] =?UTF-8?q?refactor=20:=20IV=EC=97=90=20TAG=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=EB=A5=BC=20=EB=8D=94=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=B4=20=EA=B3=A0=EB=A0=A4=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20#170?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/crypto/AESService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/crypto/AESService.kt b/src/main/kotlin/onku/backend/global/crypto/AESService.kt index cc8d223..c606879 100644 --- a/src/main/kotlin/onku/backend/global/crypto/AESService.kt +++ b/src/main/kotlin/onku/backend/global/crypto/AESService.kt @@ -53,7 +53,7 @@ class AESService( if (encrypted == null) return null return try { val decoded = Base64.getDecoder().decode(encrypted) - require(decoded.size > GCM_IV_LENGTH) { "Invalid encrypted text" } + 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) From 21b30c4b7754c2bd7edfb9cfa8ed6e9534f2c748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Sun, 28 Dec 2025 15:53:00 +0900 Subject: [PATCH 460/470] =?UTF-8?q?refactor=20:=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=8B=9C=20=EA=B3=B5=EB=B0=B1=20=EC=BC=80=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=EB=8F=84=20=EA=B3=A0=EB=A0=A4=20#170?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/crypto/converter/EncryptedStringConverter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt b/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt index 9ffffe7..0348bde 100644 --- a/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt +++ b/src/main/kotlin/onku/backend/global/crypto/converter/EncryptedStringConverter.kt @@ -15,7 +15,7 @@ class EncryptedStringConverter : AttributeConverter { } override fun convertToDatabaseColumn(raw: String?): String? { - if (raw == null) return null + if (raw.isNullOrBlank()) return null try { return encryptor().encrypt(raw) } catch (e: Exception) { From 7d9776ce92bf3c27748c127f652e533a4a5c0606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Mon, 29 Dec 2025 01:31:57 +0900 Subject: [PATCH 461/470] =?UTF-8?q?build=20:=20secret=20manager=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 1314162..4096c5e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,6 +55,9 @@ dependencies { 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") } kotlin { From 654fee42b00cd0b1927faa99a96e4f3669dcd8b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 29 Dec 2025 17:20:40 +0900 Subject: [PATCH 462/470] =?UTF-8?q?feat:=20apple=20auth=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=8B=9C?= =?UTF-8?q?=ED=81=90=EB=A6=AC=ED=8B=B0=20=ED=97=88=EC=9A=A9=20url=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#172?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 ++ .../kotlin/onku/backend/global/auth/config/SecurityConfig.kt | 1 + 2 files changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 1314162..9558c32 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,6 +55,8 @@ dependencies { implementation("com.google.auth:google-auth-library-oauth2-http:1.33.1") //test testImplementation("io.mockk:mockk:1.13.5") + //apple auth + implementation("com.nimbusds:nimbus-jose-jwt:9.40") } kotlin { diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index bd406dd..535c41a 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -28,6 +28,7 @@ class SecurityConfig( ) private val ALLOWED_POST = arrayOf( "/api/v1/auth/kakao", + "/api/v1/auth/apple", "/api/v1/auth/reissue", ) From d50db95ec4174a3dcf88a369c5cf7b684c6ebe47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Mon, 29 Dec 2025 17:28:31 +0900 Subject: [PATCH 463/470] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20configuration=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#172?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/auth/dto/AppleLoginRequest.kt | 5 +++++ .../kotlin/onku/backend/global/config/AppleProps.kt | 12 ++++++++++++ .../global/config/{KakaoConfig.kt => PropsConfig.kt} | 9 +++++++-- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/onku/backend/global/auth/dto/AppleLoginRequest.kt create mode 100644 src/main/kotlin/onku/backend/global/config/AppleProps.kt rename src/main/kotlin/onku/backend/global/config/{KakaoConfig.kt => PropsConfig.kt} (60%) 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/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/KakaoConfig.kt b/src/main/kotlin/onku/backend/global/config/PropsConfig.kt similarity index 60% rename from src/main/kotlin/onku/backend/global/config/KakaoConfig.kt rename to src/main/kotlin/onku/backend/global/config/PropsConfig.kt index a0139f7..85002a3 100644 --- a/src/main/kotlin/onku/backend/global/config/KakaoConfig.kt +++ b/src/main/kotlin/onku/backend/global/config/PropsConfig.kt @@ -4,5 +4,10 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Configuration @Configuration -@EnableConfigurationProperties(KakaoProps::class) -class KakaoConfig \ No newline at end of file +@EnableConfigurationProperties( + value = [ + KakaoProps::class, + AppleProps::class + ] +) +class PropsConfig \ No newline at end of file From 99d853d746b09b33765026f92a3ea46189e20bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 30 Dec 2025 22:11:55 +0900 Subject: [PATCH 464/470] =?UTF-8?q?feat:=20auth=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=95=AD=EB=AA=A9=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#172?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt b/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt index c2e15f4..3580e00 100644 --- a/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt @@ -9,12 +9,15 @@ enum class AuthErrorCode( override val status: HttpStatus ) : ApiErrorCode { - OAUTH_EMAIL_SCOPE_REQUIRED("AUTH400", "카카오 프로필에 이메일이 없습니다. 카카오 동의 항목(이메일)을 활성화해 주세요.", HttpStatus.BAD_REQUEST), + 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), - KAKAO_USER_ID_MISSING("AUTH400", "카카오 사용자 ID가 없습니다.", 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) } From 8eb0dc148d12d6431a6ac2e156fd5d3177477f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 30 Dec 2025 22:32:40 +0900 Subject: [PATCH 465/470] =?UTF-8?q?feat:=20member.socialId=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EB=B3=80=EA=B2=BD=20#172?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/onku/backend/domain/member/Member.kt | 2 +- .../onku/backend/domain/member/repository/MemberRepository.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/Member.kt b/src/main/kotlin/onku/backend/domain/member/Member.kt index 753a49b..2d2f811 100644 --- a/src/main/kotlin/onku/backend/domain/member/Member.kt +++ b/src/main/kotlin/onku/backend/domain/member/Member.kt @@ -24,7 +24,7 @@ class Member( val socialType: SocialType, @Column(name = "social_id", nullable = false, length = 100) - val socialId: Long, + val socialId: String, @Column(name = "has_info", nullable = false) var hasInfo: Boolean = false, diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt index 7271de9..bf520f1 100644 --- a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt @@ -8,7 +8,7 @@ import org.springframework.data.jpa.repository.Query interface MemberRepository : JpaRepository { fun findByEmail(email: String): Member? - fun findBySocialIdAndSocialType(socialId: Long, socialType: SocialType): Member? + fun findBySocialIdAndSocialType(socialId: String, socialType: SocialType): Member? @Query(""" select m.id from Member m where m.hasInfo = true From a53b3e6a5a74d9468f4590ce409cc2f2a60165c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 30 Dec 2025 22:34:10 +0900 Subject: [PATCH 466/470] =?UTF-8?q?feat:=20upsertSocialMember=20=EC=8B=9C?= =?UTF-8?q?=20email=20=EC=A1=B0=ED=9A=8C=20=EC=9D=B4=EC=A0=84=EC=97=90=20s?= =?UTF-8?q?ocialId=20=EB=A1=9C=20=EB=A8=BC=EC=A0=80=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#172?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/MemberService.kt | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt index 53af2b6..5cb9dd6 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -8,6 +8,7 @@ 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 @@ -24,24 +25,28 @@ class MemberService( ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) @Transactional - fun upsertSocialMember(email: String?, socialId: Long, type: SocialType): Member { - val existing = memberRepository.findBySocialIdAndSocialType(socialId, type) - if (existing != null) { - if (!email.isNullOrBlank() && existing.email != email) { - existing.updateEmail(email) - } - return existing + 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 } - val created = Member( - email = email, - role = Role.USER, + // 신규 생성: email 없으면 생성 불가 처리 + val safeEmail = email ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) + val newMember = Member( + email = safeEmail, socialType = type, - socialId = socialId, - hasInfo = false, - approval = ApprovalStatus.PENDING + socialId = socialId ) - return memberRepository.save(created) + return memberRepository.save(newMember) } @Transactional From 5d88caafaf06e2575bc8cad9ca2e16cc6db8bd93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 30 Dec 2025 22:39:06 +0900 Subject: [PATCH 467/470] =?UTF-8?q?feat:=20=EC=9D=B8=EA=B0=80=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=ED=86=A0=ED=81=B0=20=EA=B5=90=ED=99=98,?= =?UTF-8?q?=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=ED=81=AC=EB=A6=BF=20=EC=83=9D=EC=84=B1,=20ID=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80=20#172?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/service/AppleService.kt | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/main/kotlin/onku/backend/global/auth/service/AppleService.kt 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 From b50447e84b9575e62d5023caa8df8a3a3edc8a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 30 Dec 2025 22:42:19 +0900 Subject: [PATCH 468/470] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20(=ED=86=A0=ED=81=B0=20=EA=B5=90=ED=99=98,=20ID=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B2=80=EC=A6=9D,=20=EC=86=8C=EC=85=9C?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=20upsert)=20=EC=B6=94=EA=B0=80=20#172?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/service/AuthService.kt | 2 + .../global/auth/service/AuthServiceImpl.kt | 56 +++++++++++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt index 98aa884..22fade7 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt @@ -1,6 +1,7 @@ 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 @@ -8,6 +9,7 @@ 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 index 949d04b..6a1e119 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -6,9 +6,11 @@ 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 @@ -26,11 +28,13 @@ import java.time.Duration 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 @@ -45,7 +49,7 @@ class AuthServiceImpl( ) val profile = kakaoService.getProfile(token.accessToken) - val socialId = profile.id + val socialId = profile.id.toString() val email = profile.kakaoAccount?.email ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) @@ -55,6 +59,47 @@ class AuthServiceImpl( 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() @@ -84,7 +129,6 @@ class AuthServiceImpl( ApprovalStatus.PENDING -> { if (member.hasInfo) { - // 온보딩 제출 완료(프로필 있음) → 온보딩 토큰 미발급 ResponseEntity .status(HttpStatus.ACCEPTED) .body( @@ -98,7 +142,6 @@ class AuthServiceImpl( ) ) } else { - // 온보딩 제출 전(프로필 없음) → 온보딩 토큰 발급 val onboarding = jwtUtil.createOnboardingToken(email, onboardingTtl.toMinutes()) val headers = HttpHeaders().apply { add(HttpHeaders.AUTHORIZATION, "Bearer $onboarding") @@ -174,7 +217,12 @@ class AuthServiceImpl( @Transactional override fun withdraw(member: Member, refreshToken: String): ResponseEntity> { - kakaoService.adminUnlink(member.socialId, kakaoProps.adminKey) + 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) From f463dec9db4384647b36822827261d33a5d348bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A7=80?= Date: Tue, 30 Dec 2025 22:42:41 +0900 Subject: [PATCH 469/470] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20#172?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/global/auth/controller/AuthController.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt index d9e6cd6..7c2fe16 100644 --- a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt +++ b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt @@ -4,6 +4,7 @@ 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 @@ -22,6 +23,11 @@ class AuthController( 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> = From b30f2060bbb6b5333070d3ff64e77a12e2f803e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EB=A1=9D?= Date: Thu, 1 Jan 2026 22:58:46 +0900 Subject: [PATCH 470/470] =?UTF-8?q?test=20:=20SocialType=EC=9D=B4=20Long?= =?UTF-8?q?=20->=20String=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EB=80=9C=EC=97=90?= =?UTF-8?q?=20=EB=94=B0=EB=9D=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95=20#175?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onku/backend/crypto/CryptoJpaErrorTest.kt | 2 +- .../backend/member/service/MemberServiceTest.kt | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/test/kotlin/onku/backend/crypto/CryptoJpaErrorTest.kt b/src/test/kotlin/onku/backend/crypto/CryptoJpaErrorTest.kt index 0285fd2..3ca9e93 100644 --- a/src/test/kotlin/onku/backend/crypto/CryptoJpaErrorTest.kt +++ b/src/test/kotlin/onku/backend/crypto/CryptoJpaErrorTest.kt @@ -35,7 +35,7 @@ class CryptoJpaErrorTest( "abc@def.com", Role.USER.name, SocialType.KAKAO.name, - 1234567890, + "KAKAO", false, ApprovalStatus.APPROVED.name, false, diff --git a/src/test/kotlin/onku/backend/member/service/MemberServiceTest.kt b/src/test/kotlin/onku/backend/member/service/MemberServiceTest.kt index 72ecaa6..8e092ef 100644 --- a/src/test/kotlin/onku/backend/member/service/MemberServiceTest.kt +++ b/src/test/kotlin/onku/backend/member/service/MemberServiceTest.kt @@ -43,7 +43,7 @@ class MemberServiceTest { email: String? = "test@aaa.com", role: Role = Role.USER, socialType: SocialType = SocialType.KAKAO, - socialId: Long = 123L, + socialId: String = "KAKAO", hasInfo: Boolean = false, approval: ApprovalStatus = ApprovalStatus.PENDING, isStaff: Boolean = false @@ -94,16 +94,16 @@ class MemberServiceTest { val existing = createMember( id = 1L, email = "old@test.com", - socialId = 123L, + socialId = "KAKAO", socialType = SocialType.KAKAO ) - every { memberRepository.findBySocialIdAndSocialType(123L, SocialType.KAKAO) } returns existing + every { memberRepository.findBySocialIdAndSocialType("KAKAO", SocialType.KAKAO) } returns existing every { existing.updateEmail("new@test.com") } just Runs val result = service.upsertSocialMember( email = "new@test.com", - socialId = 123L, + socialId = "KAKAO", type = SocialType.KAKAO ) @@ -114,14 +114,14 @@ class MemberServiceTest { @Test fun `upsertSocialMember - 기존 소셜 계정이 없으면 새로 생성`() { - every { memberRepository.findBySocialIdAndSocialType(123L, SocialType.KAKAO) } returns null - + every { memberRepository.findBySocialIdAndSocialType("KAKAO", SocialType.KAKAO) } returns null + every { memberRepository.findByEmail("user@test.com") } returns null val slotMember = slot() every { memberRepository.save(capture(slotMember)) } answers { slotMember.captured } val result = service.upsertSocialMember( email = "user@test.com", - socialId = 123L, + socialId = "KAKAO", type = SocialType.KAKAO ) @@ -129,7 +129,7 @@ class MemberServiceTest { assertEquals("user@test.com", slotMember.captured.email) assertEquals(Role.USER, slotMember.captured.role) assertEquals(SocialType.KAKAO, slotMember.captured.socialType) - assertEquals(123L, slotMember.captured.socialId) + assertEquals("KAKAO", slotMember.captured.socialId) assertFalse(slotMember.captured.hasInfo) assertEquals(ApprovalStatus.PENDING, slotMember.captured.approval)